Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cefe3c22a4 |
No files matched your search
@@ -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
|
||||
|
||||
+8
-39
@@ -22,7 +22,9 @@ frontend-web/node_modules/
|
||||
frontend-web/.pnpm-store/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
frontend-web/vite-dev.log
|
||||
frontend-web/vite-dev.err.log
|
||||
frontend-web/vite-dev.err.log
|
||||
frontend-web/.rspack-cache/
|
||||
frontend-web/dist-firefox52/
|
||||
|
||||
# Frontend local packages/archives/docs (non-source)
|
||||
frontend-web/pnpm-lock.yaml
|
||||
@@ -90,9 +92,6 @@ hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_large_file_safety.py
|
||||
!offline-backend-20260512/backend/tests/test_account_authentication.py
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_room_limit.py
|
||||
!offline-backend-20260512/backend/tests/test_admin_audit_retention_control.py
|
||||
# Seeder-owned Agent Skills are product source; keep them out of the broad
|
||||
# runtime `skills/` ignore above.
|
||||
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/
|
||||
@@ -101,18 +100,8 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_seed_assets.py
|
||||
!offline-backend-20260512/backend/tests/test_latency_diagnosis_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_query_slowdown_diagnosis_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_elasticsearch_offline_ops_seed.py
|
||||
!offline-backend-20260512/backend/tests/test_task_report_convert.py
|
||||
!offline-backend-20260512/backend/tests/test_task_deeplink_details.py
|
||||
!offline-backend-20260512/backend/tests/test_offline_docs.py
|
||||
!offline-backend-20260512/backend/tests/test_migration_compatibility.py
|
||||
# Keep the admin catalog hidden-switch (agent / business chain) store regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_chain_hidden.py
|
||||
# Keep the business-chain seat enrichment (available/live_name) regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_chain_seat_enrichment.py
|
||||
# Keep the dedicated taskId + goPath history Gateway contract versioned.
|
||||
!offline-backend-20260512/backend/tests/test_task_deeplink_threads_api.py
|
||||
!offline-backend-20260512/backend/tests/test_thread_clear.py
|
||||
# Keep project-scoped cross-conversation memory regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_analysis_project_memory.py
|
||||
!offline-backend-20260512/backend/tests/test_analysis_project_memory_integration.py
|
||||
@@ -129,15 +118,6 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py
|
||||
!offline-backend-20260512/backend/tests/test_smart_reasoning_retrieval_middleware.py
|
||||
!offline-backend-20260512/backend/tests/test_knowledge_retrieval_settings.py
|
||||
!offline-backend-20260512/backend/tests/test_wiki_generation_config.py
|
||||
!offline-backend-20260512/backend/tests/test_wiki_directory_constraints.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_parent_directory_routing.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_loading.py
|
||||
!offline-backend-20260512/backend/tests/test_weknora_embed_script.py
|
||||
!offline-backend-20260512/backend/tests/test_weknora_static.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_advanced_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
@@ -197,8 +177,6 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
||||
# Per-account question recommendation preferences.
|
||||
!offline-backend-20260512/backend/tests/test_user_preferences.py
|
||||
# Writing-mode first-report pipeline enforcement regressions.
|
||||
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
|
||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||
@@ -226,7 +204,6 @@ git-clone/
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
/deerflow-frontend-dist-*.zip
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
@@ -252,22 +229,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_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/
|
||||
@@ -275,6 +242,8 @@ offline-backend-20260512/backend/uv.lock
|
||||
# Python bytecode cache
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.worker-pool/
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# Local Firefox 52 test profiles
|
||||
.firefox52-profile*/
|
||||
@@ -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`。
|
||||
@@ -88,7 +88,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **知识库深度研究(普通知识库限定问答)**: 每个可读知识库从卡片或详情页进入 `/page/workspace/knowledge/:noteId/research`。它不复用原“深度研究”流程,而是把普通 Lead Agent 问答在服务端强制绑定到单个知识库、经典检索并关闭工具/记忆;私有会话与草稿仅创建者可见。首个完整回答自动成为 Markdown 草稿,后续回答须显式“应用为文档”。可写用户保存时在该知识库 Wiki 下创建保留根目录「深度研究」,用独立 publication id 的稳定 slug 创建/覆盖文章;文件名独立于问答标题。书架文章随知识库读权限共享,但“更多”及原问答仅创建者可见;只读用户可问答和整理草稿,不能发布。前端核心页 `pages/KnowledgeResearchPage.tsx`,client `strategy-components/api/knowledge-research.ts`;后端 REST 在 `app/gateway/routers/llmwiki.py`,运行时约束 `app/gateway/knowledge_research.py`,持久化 `deerflow/persistence/knowledge_research.py`。
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items.
|
||||
@@ -99,13 +98,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **技能上下线 (Skill Availability, owner/admin)**: the global `Skill.enabled` flag is the online/offline switch. Taking a skill offline persists `skills.<name>.enabled=false` in `extensions_config.json` but does **not** delete its directory or remove it from existing agent/workflow configurations; the row remains visible and bringing it online restores those references. Administrators may toggle every skill from 「技能管理」; ordinary users may toggle only their own non-built-in `custom` skills from 「技能 → 技能列表 → 我的」. Offline skills must be absent from every model-facing skill catalogue (`lead` prompt, `search_skills`, `skill_list`, `skill_view`, subagents, AI writing/deep research/roundtable and Workflow run-start validation), from agent skill pickers, and from direct sandbox access: `read_file`/`ls`/`glob`/`grep`/`bash` reject offline, unknown, root-wide and category-wide `/mnt/skills` paths. The backend mutation is `PUT /api/skills/{name}`, authorizes either an administrator or the persisted owner of the target custom skill, patches the raw document so MCP placeholders and unrelated extension fields survive, uses atomic replacement when supported plus a Docker single-file-bind fallback, and refreshes the skill prompt cache. Launchers keep the runtime extensions file in a writable persisted directory. Existing agent/strategy-agent references stay visible as 「已下线」 and may be removed but not newly selected.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 的会话历史使用独立 `task_deeplink_threads` 索引表及 `GET /api/task-deeplink-threads?task_id=&go_path=`,前端由 `useTaskDeeplinkThreads` 读取,不再筛选通用聊天列表。LangGraph thread/checkpoint 仍是消息权威数据;专用表仅按 `(task_id, go_path)` 定位 thread。`3qfx/rwfx` 按任务 id,`xdfx` 按 URL 原始行动 id;新 thread 写 `metadata.{taskRecordId,taskGoPath,thread_type:"task_deeplink"}` 并自动登记,通用最近对话默认隐藏。迁移 `20261019_01` 只建表、**不迁移/回填旧数据**。
|
||||
- **上下文近 3 轮滑窗(管理员全局开关)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭;`GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员)。开启后 `RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS 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 提示。
|
||||
|
||||
|
||||
@@ -112,8 +112,6 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
### Frontend
|
||||
|
||||
- **账号登录、自主注册与单点登录**:`/login` 提供用户名/邮箱与密码登录,`/register` 提供自主注册。管理员在「设置 → 登录与注册」管理 `system_settings.json.authentication` 的四个开关:账号密码登录、自主注册、单点登录(同一账号仅一处在线)默认关闭;既有外部系统免登 `sso_enabled` 默认开启。已保存配置保持不变。开启密码登录后,私有页面先经 `/api/v1/auth/me` 验证,后端 Gateway/LangGraph 同步拒绝旧免密 JWT;合法登录在 JWT 写入 `auth_method`。未设置个人密码的账号使用配置中的系统口令,未自定义时为 `123ewq`;修改密码也须验证当前个人密码或默认口令。开启 `single_session_enabled` 后,每次成功登录原子递增 `token_version`,旧会话下一次请求即失效;前端每 15 秒及窗口激活时检查并退出,失效页禁止自动重登录,避免互踢。关闭单处限制时允许多个登录会话。外部免登参数兼容 hash 前后两种位置(hash 参数优先)。公开配置 `GET /api/v1/auth/options`,管理员读写 `GET/PUT /api/system-settings/authentication`。个人信息下提供修改密码;`LoginWhitelistPage.tsx` 提供管理员重置。密码与口令共用,更新后旧密码/链接失效并撤销旧会话;当前用户改密或管理员重置自身须保存返回的新 Bearer token。新密码不在用户列表回传明文,管理员仅在本次重置后临时复制链接。注册与新建外部账号均为普通用户,禁止依据名称自动授予管理员权限。
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
@@ -125,7 +123,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级登录口令与密码**已统一,个人口令优先于系统共享口令;新密码保存哈希,旧明文口令兼容校验。管理员重置使用 `PUT /api/v1/auth/users/{id}/login-password`(别名 `/reset-password`),同时更新两种历史凭据字段并撤销旧会话,不再支持清空密码或读取新密码明文。未设置个人密码时使用配置共享口令(默认 `123ewq`);登录开关关闭时保留旧入口兼容行为,开启后禁止免密进入。详见上方「账号登录、自主注册与单点登录」。
|
||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
|
||||
@@ -133,12 +131,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`。
|
||||
|
||||
|
||||
@@ -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、数据库密码或未经检查的完整业务日志。
|
||||
@@ -1,7 +1,6 @@
|
||||
importScripts("config.js", "shared.js");
|
||||
|
||||
const ALARM_NAME = "deer-browser-task-poll";
|
||||
const SERVICE_DISCOVERY_TTL_MS = 10 * 60 * 1000;
|
||||
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
|
||||
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
||||
let pollTimer = null;
|
||||
@@ -19,8 +18,6 @@ async function getSettings() {
|
||||
const discovered = await chrome.storage.local.get({
|
||||
discoveredApiBaseUrl: "",
|
||||
discoveredWebBaseUrl: "",
|
||||
lastServiceSyncAt: "",
|
||||
lastServiceSyncSource: "",
|
||||
});
|
||||
if (settings.autoDiscoverServiceUrl) {
|
||||
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
|
||||
@@ -36,8 +33,6 @@ async function getSettings() {
|
||||
if (!settings.workerId) {
|
||||
updates.workerId = deerMakeWorkerId();
|
||||
}
|
||||
settings.lastServiceSyncAt = discovered.lastServiceSyncAt || "";
|
||||
settings.lastServiceSyncSource = discovered.lastServiceSyncSource || "";
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await chrome.storage.sync.set(updates);
|
||||
Object.assign(settings, updates);
|
||||
@@ -181,17 +176,6 @@ async function discoverServiceBases(settings, force = false) {
|
||||
if (!settings.autoDiscoverServiceUrl && !force) {
|
||||
return settings;
|
||||
}
|
||||
const lastSync = Date.parse(settings.lastServiceSyncAt || "");
|
||||
const discoveryAge = Date.now() - lastSync;
|
||||
if (
|
||||
!force &&
|
||||
Number.isFinite(lastSync) &&
|
||||
settings.lastServiceSyncSource &&
|
||||
discoveryAge >= 0 &&
|
||||
discoveryAge < SERVICE_DISCOVERY_TTL_MS
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||
let discoveredWebBaseUrl = webBaseUrl;
|
||||
|
||||
@@ -9,8 +9,6 @@ const BACKGROUND_PATH = path.join(__dirname, "background.js");
|
||||
function createHarness() {
|
||||
let storageChangedListener;
|
||||
let alarmCreateCount = 0;
|
||||
const localData = {};
|
||||
const fetchUrls = [];
|
||||
const settings = {
|
||||
apiBaseUrl: "http://localhost:8001",
|
||||
webBaseUrl: "http://localhost:5174",
|
||||
@@ -38,8 +36,8 @@ function createHarness() {
|
||||
},
|
||||
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: {
|
||||
@@ -58,13 +56,8 @@ function createHarness() {
|
||||
const context = {
|
||||
chrome,
|
||||
console,
|
||||
fetch: async (url) => {
|
||||
fetchUrls.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ enabled: true }),
|
||||
};
|
||||
fetch: async () => {
|
||||
throw new Error("offline test");
|
||||
},
|
||||
importScripts: () => {},
|
||||
setTimeout: () => 1,
|
||||
@@ -86,10 +79,6 @@ function createHarness() {
|
||||
storageChangedListener(changes, "sync");
|
||||
},
|
||||
getAlarmCreateCount: () => alarmCreateCount,
|
||||
getFetchUrls: () => [...fetchUrls],
|
||||
getSettings: () => context.getSettings(),
|
||||
discoverServiceBases: (settings, force = false) =>
|
||||
context.discoverServiceBases(settings, force),
|
||||
resetAlarmCreateCount: () => {
|
||||
alarmCreateCount = 0;
|
||||
},
|
||||
@@ -139,18 +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, 2);
|
||||
});
|
||||
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
const colorOf = name => AV_COLORS[[...name].reduce((a, c) => a + c.charCodeAt(0), 0) % AV_COLORS.length];
|
||||
const avatarHtml = (name, cls = "") =>
|
||||
`<div class="avatar ${cls}" style="background:${colorOf(name)}">${esc(name.slice(0, 1))}</div>`;
|
||||
|
||||
const ICONS = {
|
||||
like: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.3a2 2 0 0 0 2-1.7l1.4-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>',
|
||||
likeFilled: '<svg width="14" height="14" viewBox="0 0 24 24" fill="#1877f2" stroke="#1877f2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.3a2 2 0 0 0 2-1.7l1.4-9a2 2 0 0 0-2-2.3z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>',
|
||||
heart: '<svg width="14" height="14" viewBox="0 0 24 24" fill="#f3425f"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21.2l7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>',
|
||||
comment: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>',
|
||||
share: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>',
|
||||
globe: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a13.5 13.5 0 0 1 0 18 13.5 13.5 0 0 1 0-18z"/></svg>',
|
||||
friends: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="8.5" r="3"/><path d="M3 20c0-3.2 2.7-5 6-5s6 1.8 6 5"/><circle cx="17" cy="9.5" r="2.3"/></svg>',
|
||||
dots: '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>',
|
||||
mountain: '<svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-5-5-9 8"/></svg>',
|
||||
link: '<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>',
|
||||
check: '<svg width="14" height="14" viewBox="0 0 24 24" fill="#1877f2"><circle cx="12" cy="12" r="10"/><path d="M17.5 9.5 10.7 16 7 12.3l1.4-1.4 2.3 2.3 5.4-5.2z" fill="#fff"/></svg>',
|
||||
plus: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
};
|
||||
|
||||
const audienceIcon = a => (a === "好友" ? ICONS.friends : ICONS.globe);
|
||||
const isImg = v => /^(https?:|data:|\.?\/)/.test(v);
|
||||
|
||||
function tileHtml(v, i) {
|
||||
const inner = isImg(v)
|
||||
? `<img src="${esc(v)}" alt="">`
|
||||
: `<div class="img-ph" style="background:${PH_GRADIENTS[i % PH_GRADIENTS.length]}">${ICONS.mountain}</div>`;
|
||||
return `<div class="tile">${inner}</div>`;
|
||||
}
|
||||
|
||||
function mediaHtml(images) {
|
||||
if (!images || !images.length) return "";
|
||||
const shown = images.slice(0, 4);
|
||||
const cls = `n${Math.min(shown.length, 4)}`;
|
||||
let html = shown.map((v, i) => tileHtml(v, i)).join("");
|
||||
if (images.length > 4) {
|
||||
html = html.replace(/<div class="tile">[\s\S]*<\/div>$/, m =>
|
||||
m + `<div class="tile-more">+${images.length - 3}</div>`);
|
||||
}
|
||||
return `<div class="post-media ${cls}">${html}</div>`;
|
||||
}
|
||||
|
||||
function linkCardHtml(link) {
|
||||
if (!link) return "";
|
||||
return `
|
||||
<div class="link-card">
|
||||
<div class="link-img"><div class="img-ph" style="background:linear-gradient(135deg,#a8c5f0,#dfe7f5)">${ICONS.link}</div></div>
|
||||
<div class="link-body">
|
||||
<div class="link-domain">${esc(link.domain)}</div>
|
||||
<div class="link-title">${esc(link.title)}</div>
|
||||
<div class="link-desc">${esc(link.desc || "")}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function commentHtml(c) {
|
||||
return `
|
||||
<div class="comment">
|
||||
${avatarHtml(c.name, "sm")}
|
||||
<div style="min-width:0">
|
||||
<div class="c-bubble">
|
||||
<div class="c-name">${esc(c.name)}</div>
|
||||
<div class="c-text">${esc(c.text)}</div>
|
||||
</div>
|
||||
<div class="c-foot">
|
||||
<span>赞 ${c.likes}</span><span>回复</span><span>${esc(c.time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function postHtml(p) {
|
||||
const comments = (p.comments || []).map(commentHtml).join("");
|
||||
const commentCount = p.comments.length;
|
||||
return `
|
||||
<article class="card post">
|
||||
<div class="post-head">
|
||||
${avatarHtml(p.name)}
|
||||
<div class="ph-meta">
|
||||
<div class="ph-name">${esc(p.name)}${p.verified ? ICONS.check : ""}</div>
|
||||
<div class="ph-sub">${audienceIcon(p.audience)} ${esc(p.time)}</div>
|
||||
</div>
|
||||
<button class="ph-more">${ICONS.dots}</button>
|
||||
</div>
|
||||
${p.text ? `<div class="post-text">${esc(p.text)}</div>` : ""}
|
||||
${mediaHtml(p.images)}
|
||||
${linkCardHtml(p.link)}
|
||||
<div class="stats">
|
||||
<span class="rx">${ICONS.likeFilled}</span><span class="rx">${ICONS.heart}</span>
|
||||
<span class="like-count" data-base="${p.likes}">${p.likes}</span>
|
||||
<span class="grow"></span>
|
||||
${commentCount ? `<span>${commentCount} 条评论</span>` : ""}
|
||||
${p.shares ? `<span>· ${p.shares} 次分享</span>` : ""}
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<button class="p-action act-like">${ICONS.like}<span>赞</span></button>
|
||||
<button class="p-action">${ICONS.comment}<span>评论</span></button>
|
||||
<button class="p-action">${ICONS.share}<span>分享</span></button>
|
||||
</div>
|
||||
${comments ? `<div class="comments">${comments}</div>` : ""}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function storiesHtml() {
|
||||
const items = STORIES.map((n, i) => `
|
||||
<div class="story" style="background:${STORY_GRADIENTS[i % STORY_GRADIENTS.length]}">
|
||||
<div class="story-meta">
|
||||
<div class="avatar sm" style="background:${colorOf(n)}">${esc(n.slice(0, 1))}</div>
|
||||
<div class="sm-name">${esc(n)}</div>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
return `
|
||||
<div class="story story-create">
|
||||
<div class="sc-img">${ICONS.mountain}</div>
|
||||
<div class="sc-plus">${ICONS.plus}</div>
|
||||
<div class="sc-label">创建快拍</div>
|
||||
</div>${items}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("posts").innerHTML = FEED.map(postHtml).join("");
|
||||
document.getElementById("stories").innerHTML = storiesHtml();
|
||||
document.getElementById("sponsored").innerHTML = SPONSORED.map(s => `
|
||||
<div class="sponsored">
|
||||
<div class="sq"><div class="img-ph" style="background:linear-gradient(135deg,#c9d8ef,#e9eff8)">${ICONS.mountain}</div></div>
|
||||
<div style="min-width:0">
|
||||
<div class="sp-title">${esc(s.title)}</div>
|
||||
<div class="sp-domain">${esc(s.domain)}</div>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
document.getElementById("contacts").innerHTML = CONTACTS.map(n => `
|
||||
<div class="contact-row">
|
||||
<div class="contact-av">${avatarHtml(n, "sm")}<span class="online-dot"></span></div>
|
||||
${esc(n)}
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
/* 点赞按钮可点击切换(纯前端演示) */
|
||||
document.addEventListener("click", e => {
|
||||
const btn = e.target.closest(".act-like");
|
||||
if (!btn) return;
|
||||
const count = btn.closest(".post").querySelector(".like-count");
|
||||
const base = Number(count.dataset.base);
|
||||
const liked = btn.classList.toggle("liked");
|
||||
count.textContent = base + (liked ? 1 : 0);
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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/应用锁路径。
|
||||
@@ -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,'<')}</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>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" suppressHydrationWarning data-browser-compat="firefox52">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute("data-browser-compat", "firefox52");
|
||||
var theme = localStorage.getItem("strategy-theme");
|
||||
root.classList.remove("dark", "light");
|
||||
if (theme !== "light") root.classList.add("dark");
|
||||
} catch (error) {}
|
||||
})();
|
||||
(function () {
|
||||
try {
|
||||
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
|
||||
return;
|
||||
}
|
||||
AbortSignal.timeout = function (milliseconds) {
|
||||
var controller = new AbortController();
|
||||
var timer = setTimeout(function () {
|
||||
var reason;
|
||||
try {
|
||||
reason = new DOMException("signal timed out", "TimeoutError");
|
||||
} catch (error) {
|
||||
reason = new Error("signal timed out");
|
||||
reason.name = "TimeoutError";
|
||||
}
|
||||
try {
|
||||
controller.abort(reason);
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
}
|
||||
}, milliseconds);
|
||||
controller.signal.addEventListener("abort", function () {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
return controller.signal;
|
||||
};
|
||||
} catch (error) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>CM 助手</title>
|
||||
<script src="/runtime-config.js" data-runtime-config></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
"use strict";
|
||||
|
||||
const { transformSync } = require("@babel/core");
|
||||
const presetEnv = require("@babel/preset-env");
|
||||
|
||||
// Firefox 52 implements async functions, but async iteration arrived later.
|
||||
// Babel must therefore lower both async-generator declarations and `for await`
|
||||
// consumers. Leaving the latter to SWC turns them into a synchronous
|
||||
// `value[Symbol.iterator]()` loop and breaks LangGraph streams at runtime.
|
||||
const ASYNC_ITERATION_RE =
|
||||
/async\s+(?:function\s*)?\*|\bfor\s+await\s*\(/;
|
||||
|
||||
module.exports = function firefox52LegacyGeneratorLoader(source, inputMap) {
|
||||
if (!ASYNC_ITERATION_RE.test(source)) {
|
||||
this.callback(null, source, inputMap);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cacheable?.(true);
|
||||
const common = {
|
||||
filename: this.resourcePath,
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
sourceType: "unambiguous",
|
||||
sourceMaps: false,
|
||||
compact: false,
|
||||
};
|
||||
|
||||
// Babel's regenerator transform cannot consume several modern expressions
|
||||
// inside async generators in one pass. Lower those expressions first while
|
||||
// deliberately preserving async/generator syntax, then run the Firefox 52
|
||||
// pass. Only 24 LangChain/LangGraph modules currently enter this slow path.
|
||||
const normalized = transformSync(source, {
|
||||
...common,
|
||||
presets: [
|
||||
[
|
||||
presetEnv,
|
||||
{
|
||||
targets: { ie: "11" },
|
||||
bugfixes: true,
|
||||
modules: false,
|
||||
useBuiltIns: false,
|
||||
exclude: [
|
||||
"@babel/plugin-transform-async-generator-functions",
|
||||
"@babel/plugin-transform-async-to-generator",
|
||||
"@babel/plugin-transform-regenerator",
|
||||
// Preserve `for await` for the second pass. Transforming it as a
|
||||
// plain `for of` here loses async-iterator semantics permanently.
|
||||
"@babel/plugin-transform-for-of",
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const result = transformSync(normalized?.code ?? source, {
|
||||
...common,
|
||||
presets: [
|
||||
[
|
||||
presetEnv,
|
||||
{
|
||||
targets: { firefox: "52" },
|
||||
bugfixes: true,
|
||||
modules: false,
|
||||
useBuiltIns: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
this.callback(null, result?.code ?? normalized?.code ?? source, inputMap);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const loader = require("./legacy-generator-loader.cjs");
|
||||
|
||||
function compile(source) {
|
||||
let output;
|
||||
let thrown;
|
||||
loader.call(
|
||||
{
|
||||
resourcePath: "langgraph-sdk/dist/ui/manager.js",
|
||||
cacheable() {},
|
||||
callback(error, code) {
|
||||
thrown = error;
|
||||
output = code;
|
||||
},
|
||||
},
|
||||
source,
|
||||
undefined,
|
||||
);
|
||||
if (thrown) throw thrown;
|
||||
return output;
|
||||
}
|
||||
|
||||
test("keeps for-await asynchronous while lowering it for Firefox 52", () => {
|
||||
const output = compile(`
|
||||
export async function consume(run) {
|
||||
for await (const value of run) {
|
||||
consumeValue(value);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.match(output, /_asyncIterator\(run\)/);
|
||||
assert.doesNotMatch(output, /run\[Symbol\.iterator\]/);
|
||||
assert.doesNotMatch(output, /for\s+await/);
|
||||
});
|
||||
|
||||
test("passes ordinary modules through without invoking Babel", () => {
|
||||
const source = "export const value = 42;";
|
||||
assert.equal(compile(source), source);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import autoprefixer from "autoprefixer";
|
||||
import firefox52RgbVars from "../../scripts/postcss-firefox52-rgb-vars.mjs";
|
||||
import firefox52Where from "../../scripts/postcss-firefox52-where.mjs";
|
||||
|
||||
export default {
|
||||
plugins: [firefox52RgbVars, firefox52Where, autoprefixer],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import tailwindcss from "tailwindcss";
|
||||
import darkDescendant from "../../scripts/postcss-dark-descendant.mjs";
|
||||
import firefox52RgbVars from "../../scripts/postcss-firefox52-rgb-vars.mjs";
|
||||
import firefox52Where from "../../scripts/postcss-firefox52-where.mjs";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss,
|
||||
darkDescendant,
|
||||
firefox52RgbVars,
|
||||
firefox52Where,
|
||||
autoprefixer,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
function replaceRequired(source, search, replacement, label) {
|
||||
const result = source.split(search).join(replacement);
|
||||
if (result === source) {
|
||||
throw new Error(`Firefox 52 compatibility patch no longer matches ${label}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = function firefox52SourceCompatLoader(source) {
|
||||
this.cacheable?.(true);
|
||||
const id = this.resourcePath.replaceAll("\\", "/");
|
||||
|
||||
if (/\/p-queue\/dist\/index\.js$/.test(id) && source.includes("#idAssigner = 1n;")) {
|
||||
return replaceRequired(source, "#idAssigner = 1n;", "#idAssigner = 1;", "p-queue");
|
||||
}
|
||||
|
||||
if (/\/mdast-util-gfm-autolink-literal\/lib\/index\.js$/.test(id)) {
|
||||
return replaceRequired(
|
||||
source,
|
||||
"(?<=^|\\s|\\p{P}|\\p{S})",
|
||||
"",
|
||||
"mdast-util-gfm-autolink-literal",
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes("/oniguruma-parser/")) {
|
||||
return source
|
||||
.split("\\p{Alpha}\\p{Pc}").join("A-Za-z_")
|
||||
.split("\\p{AHex}").join("[A-Fa-f0-9]")
|
||||
.split("\\p{Alpha}").join("[A-Za-z]")
|
||||
.split('"gsu"').join('"gu"');
|
||||
}
|
||||
|
||||
if (/\/node_modules\/mermaid\/dist\/chunks\/.*\.mjs$/.test(id)) {
|
||||
// Mermaid's generated Jison lexers put `break` immediately after `return`.
|
||||
// Firefox 52 reports every one as an unreachable-code warning. Removing
|
||||
// those dead statements is semantics-preserving and keeps DevTools usable.
|
||||
return source.replace(
|
||||
/(\breturn(?:\s+[^;\r\n]+)?;)(\r?\n[ \t]*)break;/g,
|
||||
"$1$2",
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/web-streams-polyfill\/dist\/ponyfill\.es5\.mjs$/.test(id)) {
|
||||
// The distributed ES5 ponyfill declares a local variable after an
|
||||
// unconditional return. Move the declaration before the return so Firefox
|
||||
// does not report it as unreachable while preserving its function scope.
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"function xt(e){return",
|
||||
"function xt(e){var o;return",
|
||||
"web-streams ponyfill local declaration",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
";var o}function Yt",
|
||||
";}function Yt",
|
||||
"web-streams ponyfill unreachable declaration",
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/node_modules\/@xyflow\/react\/dist\/esm\/index\.(?:js|mjs)$/.test(id)) {
|
||||
// React Flow 12.12 uses BigInt only as a monotonically increasing render
|
||||
// serial. Firefox 52 has no BigInt; Number preserves the queue semantics
|
||||
// because this counter cannot approach MAX_SAFE_INTEGER in one session.
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"useState(BigInt(0))",
|
||||
"useState(0)",
|
||||
"@xyflow/react queue serial initializer",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
"setSerial(n => n + BigInt(1))",
|
||||
"setSerial(n => n + 1)",
|
||||
"@xyflow/react queue serial increment",
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes("/node_modules/react-router/")) {
|
||||
let result = source;
|
||||
if (result.includes("async function loadRouteModule(")) {
|
||||
const start = result.indexOf("async function loadRouteModule(");
|
||||
const endMarker = "\n\n// lib/dom/ssr/links.ts";
|
||||
const end = result.indexOf(endMarker, start);
|
||||
if (end < 0) {
|
||||
throw new Error("Firefox 52 compatibility patch no longer matches React Router route modules");
|
||||
}
|
||||
const compatible = `async function loadRouteModule(route, routeModulesCache) {
|
||||
if (route.id in routeModulesCache) {
|
||||
return routeModulesCache[route.id];
|
||||
}
|
||||
throw new Error("External React Router route modules require native dynamic import and are unavailable in Firefox 52");
|
||||
}`;
|
||||
result = result.slice(0, start) + compatible + result.slice(end);
|
||||
}
|
||||
if (result.includes("function prefetchModule(modulePath)")) {
|
||||
result = replaceRequired(
|
||||
result,
|
||||
`function prefetchModule(modulePath) {
|
||||
import(
|
||||
/* @vite-ignore */
|
||||
/* webpackIgnore: true */
|
||||
modulePath
|
||||
);
|
||||
}`,
|
||||
`function prefetchModule(modulePath) {
|
||||
return Promise.resolve(modulePath);
|
||||
}`,
|
||||
"React Router module prefetch",
|
||||
);
|
||||
}
|
||||
const dynamicImportPattern = /import\(\s*\/\* @vite-ignore \*\/\s*\/\* webpackIgnore: true \*\/\s*/g;
|
||||
const withoutIgnoredImports = result.replace(
|
||||
dynamicImportPattern,
|
||||
"firefox52UnsupportedDynamicImport(",
|
||||
);
|
||||
if (withoutIgnoredImports !== result) {
|
||||
result = `${withoutIgnoredImports}
|
||||
|
||||
function firefox52UnsupportedDynamicImport(modulePath) {
|
||||
return Promise.reject(new Error(
|
||||
"External React Router module " + modulePath + " requires native dynamic import",
|
||||
));
|
||||
}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (/\/node_modules\/zod\/v4\/core\/util\.js$/.test(id)) {
|
||||
return source
|
||||
.split('int64: [/* @__PURE__*/ BigInt("-9223372036854775808"), /* @__PURE__*/ BigInt("9223372036854775807")],')
|
||||
.join("int64: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],")
|
||||
.split('uint64: [/* @__PURE__*/ BigInt(0), /* @__PURE__*/ BigInt("18446744073709551615")],')
|
||||
.join("uint64: [0, Number.MAX_SAFE_INTEGER],");
|
||||
}
|
||||
|
||||
if (/\/langsmith\/dist\/_openapi_client\/internal\/utils\/path\.(?:js|cjs)$/.test(id)) {
|
||||
const original = `const invalidSegmentPattern = /(?<=^|\\/)(?:\\.|%2e){1,2}(?=\\/|$)/gi;
|
||||
let match;
|
||||
// Find all invalid segments
|
||||
while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) {
|
||||
invalidSegments.push({
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
error: \`Value "\${match[0]}" can\\'t be safely passed as a path parameter\`,
|
||||
});
|
||||
}`;
|
||||
const compatible = `const invalidSegmentPattern = /(?:^|\\/)((?:\\.|%2e){1,2})(?=\\/|$)/gi;
|
||||
let match;
|
||||
// Firefox 52 has no RegExp lookbehind. Keep the separator in the full
|
||||
// match and report only the captured path segment.
|
||||
while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) {
|
||||
const segment = match[1];
|
||||
invalidSegments.push({
|
||||
start: match.index + match[0].length - segment.length,
|
||||
length: segment.length,
|
||||
error: \`Value "\${segment}" can\\'t be safely passed as a path parameter\`,
|
||||
});
|
||||
}`;
|
||||
return replaceRequired(source, original, compatible, "langsmith path validation");
|
||||
}
|
||||
|
||||
if (/\/langsmith\/dist\/utils\/sampling\.js$/.test(id)) {
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"const SAMPLING_HASH_MODULUS = 1000000n;",
|
||||
'const SAMPLING_HASH_MODULUS = typeof BigInt === "function" ? BigInt(1000000) : 1000000;',
|
||||
"langsmith sampling modulus",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
"const bucket = XXH3_128(encoder.encode(identifier.toLowerCase())) % SAMPLING_HASH_MODULUS;\n return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate;",
|
||||
`const hash = XXH3_128(encoder.encode(identifier.toLowerCase()));
|
||||
const bucket = hash instanceof Uint8Array
|
||||
? new DataView(hash.buffer, hash.byteOffset, hash.byteLength).getUint32(0, false) % 1000000
|
||||
: hash % SAMPLING_HASH_MODULUS;
|
||||
return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate;`,
|
||||
"langsmith sampling fallback",
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/langsmith\/dist\/utils\/xxhash\/xxhash\.js$/.test(id)) {
|
||||
const fallback = `
|
||||
function hash128WithoutBigInt(data) {
|
||||
const states = [2166136261, 2246822507, 3266489909, 668265263];
|
||||
const primes = [16777619, 3266489917, 668265263, 374761393];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const value = data[i] + i;
|
||||
for (let j = 0; j < states.length; j++) {
|
||||
states[j] = Math.imul(states[j] ^ (value + j * 97), primes[j]) >>> 0;
|
||||
states[j] = (states[j] ^ (states[j] >>> 13)) >>> 0;
|
||||
}
|
||||
}
|
||||
const result = new Uint8Array(16);
|
||||
const view = new DataView(result.buffer);
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
let value = states[i] ^ data.length;
|
||||
value = Math.imul(value ^ (value >>> 16), 2246822507) >>> 0;
|
||||
value = Math.imul(value ^ (value >>> 13), 3266489909) >>> 0;
|
||||
view.setUint32(i * 4, (value ^ (value >>> 16)) >>> 0, false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
`;
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"const n = (n) => BigInt(n);",
|
||||
'const n = (value) => typeof BigInt === "function" ? BigInt(value) : 0;',
|
||||
"langsmith BigInt initializer",
|
||||
);
|
||||
result = replaceRequired(
|
||||
result,
|
||||
"export function XXH3_128(data, seed = n(0)) {",
|
||||
`${fallback}\nexport function XXH3_128(data, seed = n(0)) {\n if (typeof BigInt !== "function" || typeof BigUint64Array !== "function" || typeof DataView.prototype.getBigUint64 !== "function") {\n return hash128WithoutBigInt(data);\n }`,
|
||||
"langsmith XXH3 fallback",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
"export function xxh128ToBytes(hash128) {",
|
||||
"export function xxh128ToBytes(hash128) {\n if (hash128 instanceof Uint8Array) return hash128;",
|
||||
"langsmith byte conversion",
|
||||
);
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const loader = require("./source-compat-loader.cjs");
|
||||
|
||||
function compile(source, resourcePath) {
|
||||
return loader.call({ resourcePath, cacheable() {} }, source);
|
||||
}
|
||||
|
||||
test("replaces only XYFlow's render serial BigInt operations", () => {
|
||||
const output = compile(
|
||||
"const [serial, setSerial] = useState(BigInt(0)); setSerial(n => n + BigInt(1));",
|
||||
"C:/repo/node_modules/@xyflow/react/dist/esm/index.js",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
output,
|
||||
"const [serial, setSerial] = useState(0); setSerial(n => n + 1);",
|
||||
);
|
||||
});
|
||||
|
||||
test("fails when the pinned XYFlow source no longer matches", () => {
|
||||
assert.throws(
|
||||
() => compile("const serial = 0;", "C:/repo/node_modules/@xyflow/react/dist/esm/index.js"),
|
||||
/compatibility patch no longer matches @xyflow\/react queue serial initializer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("removes Mermaid Jison breaks that Firefox reports as unreachable", () => {
|
||||
const output = compile(
|
||||
'switch (rule) {\n case 1:\n return "TEXT";\n break;\n case 2:\n return 2;\n break;\n}',
|
||||
"C:/repo/node_modules/mermaid/dist/chunks/mermaid.core/flow.mjs",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(output, /break;/);
|
||||
assert.match(output, /return "TEXT";/);
|
||||
assert.match(output, /return 2;/);
|
||||
});
|
||||
|
||||
test("moves the web streams ponyfill declaration before its return", () => {
|
||||
const output = compile(
|
||||
"function xt(e){return make(e);var o}function Yt(e){return e}",
|
||||
"C:/repo/node_modules/web-streams-polyfill/dist/ponyfill.es5.mjs",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
output,
|
||||
"function xt(e){var o;return make(e);}function Yt(e){return e}",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
# Firefox 52 第三方依赖兼容清单
|
||||
|
||||
本文记录 `fix/firefox52-compat` 分支中会影响 Firefox 52 的依赖、处理方式和回归入口。版本以 2026-10-09 的 `pnpm-lock.yaml` 实际解析结果为准;升级表内依赖后必须重新执行文末检查。
|
||||
|
||||
## 分级
|
||||
|
||||
| 等级 | 含义 | 当前处理 |
|
||||
|---|---|---|
|
||||
| A | 原生可运行,或由项目统一 SWC 转换即可运行 | 使用 Rspack 的 Firefox 52 单目标转换 |
|
||||
| B | 语法可转换,但缺少浏览器 API | 在 `src/core/compat/firefox52.ts` 做能力检测后安装 polyfill |
|
||||
| C | 依赖源码含 async iterator 等需要额外 Babel 转换的语法 | 精确 allowlist 后交给专用 loader |
|
||||
| D | 含 BigInt、危险正则或平台实现差异,普通转译不能解决 | 固定版本并做带断言的精确源码替换或替代实现 |
|
||||
| E | Firefox 52 网络层不提供现代流式 fetch 等能力 | 提供保持业务协议的运行时回退 |
|
||||
|
||||
## 已验证依赖
|
||||
|
||||
| 依赖/能力 | 实际版本 | 等级 | 处理与验证 |
|
||||
|---|---:|:---:|---|
|
||||
| React / React DOM | 18.3.1 | A | 兼容分支从 React 19 降为 18.3;所有页面统一使用这一运行时。118 条路由矩阵用于发现 Provider/Hook 运行时问题。 |
|
||||
| React Router DOM | 7.18.4 | D | `source-compat-loader.cjs` 处理动态路由模块加载差异;Hash 路由、重定向、刷新、公开页和 iframe 均进入完整路由冒烟。 |
|
||||
| Tailwind CSS | 3.4.19 | A | 采用 Tailwind 3 生成静态 utility;PostCSS 展平 `@layer`,转换 `:where()`、暗色后代规则和 CSS 变量透明色。 |
|
||||
| Rspack | 2.2.8 | A | `target: ["web", "es5"]`,SWC/Lightning CSS 的目标均为 Firefox 52;使用普通 script 与 Rspack chunk loader。 |
|
||||
| `@langchain/core` | 1.2.17 | C | LangChain 包进入 async-iteration loader;真实问答验证 thread 创建、XHR 流、导航和最终回复。 |
|
||||
| `@langchain/langgraph-sdk` | 1.12.3 | C | 对 async generator 和普通 async 方法中的 `for await` 做两阶段 Babel 转换;回归测试防止退化为同步 `Symbol.iterator`。 |
|
||||
| LangSmith | 0.10.8 | D | 进入源码兼容 loader,替换 Firefox 52 不支持的 BigInt/哈希实现路径;产物静态扫描兜底。 |
|
||||
| `p-queue` | 6.6.2 / 9.3.3 | D | 精确处理 BigInt 并发计数;loader 找不到预期源码时直接让构建失败,避免静默输出危险代码。 |
|
||||
| React Flow (`@xyflow/react`) | 12.12.0 | D | 仅把内部渲染序号的 `BigInt(0)` 和递增改为 Number;流程图相关路由已在 Firefox 52 中加载。 |
|
||||
| Shiki | 3.15.0 | D | 重型模块按需分块;其 Oniguruma JS 引擎进入下面的正则兼容处理。 |
|
||||
| Oniguruma parser | 0.12.2 | D | 移除 Firefox 52 不支持且不改变当前匹配语义的正则 flag;代码高亮页面与产物语法检查共同验证。 |
|
||||
| Mermaid | 11.17.2 | C | 由 Rspack/SWC 转换并按需加载;Markdown/研究/会商入口纳入路由矩阵。 |
|
||||
| Zod | 3.24.x(直接依赖) | D | 依赖源码进入兼容 loader,处理 BigInt 上下界;扫描最终 chunks 是否残留危险语法。 |
|
||||
| MapLibre GL | 4.7.1 | D | 固定在可转换版本;保持 Leaflet/静态信息回退,避免升级后引入 Firefox 52 无法执行的 Worker/语法。 |
|
||||
| CodeMirror / Tiptap / KaTeX / Marked | 锁文件版本 | A/C | 由 SWC 统一转换并路由级加载;Markdown 编辑器、预览和 Word 导出均有真实浏览器冒烟。 |
|
||||
| Radix UI / TDesign / cmdk | 锁文件版本 | A/B | Pointer Events、ResizeObserver、Portal 点击和滚动锁由兼容入口及分层 CSS 处理;菜单、选择器、弹窗、Tabs 有点击测试。 |
|
||||
| `docx` / `file-saver` | 9.5.1 / 2.0.5 | B | 正式 Word 由后端生成;Firefox 52 冒烟验证非空 DOCX Blob、MIME 和 `.docx` 下载文件名。 |
|
||||
| xlsx 解压 | fflate 0.8.3 | D | 浏览器端解压路径改为 fflate,避开原实现的现代能力依赖。 |
|
||||
|
||||
## 浏览器 API 与平台回退
|
||||
|
||||
`src/core/compat/firefox52.ts` 在应用入口最先加载,按能力补充 AbortController、AbortSignal、Observers、Web Streams、Pointer Events、`requestSubmit`、`scrollIntoView`、Clipboard、Blob/File、UUID 等能力。所有补丁先检查原生实现,现代浏览器不会被无条件覆盖。
|
||||
|
||||
Firefox 52 的 `fetch()` 没有可供业务消费的流式 `Response.body`。`src/core/compat/firefox52-fetch.ts` 用 `XMLHttpRequest.onprogress` 构造兼容的增量响应,保留请求头、鉴权、取消、错误状态和 SSE/NDJSON 分片语义。该路径属于 E 级平台回退,不能删除后只依赖 Web Streams polyfill。
|
||||
|
||||
## 升级规则
|
||||
|
||||
1. 修改表内版本后先运行 `pnpm test:firefox52-build`。精确源码片段不匹配会直接失败,此时需重新审计依赖实现,不能放宽成无条件替换。
|
||||
2. 执行 `pnpm build:firefox52 && pnpm check:firefox52`,确认最终 HTML、CSS、JS 与所有动态 chunks 都能被 Firefox 52 解析。
|
||||
3. 启动 Firefox 52 后执行 `pnpm smoke:firefox52:themes`、`pnpm smoke:firefox52:live` 和 `pnpm smoke:firefox52:full`。
|
||||
4. LangChain、LangGraph、LangSmith、p-queue、React Router、XYFlow、Oniguruma、Shiki、Mermaid、MapLibre 任一升级,都必须重新跑真实流式问答和相关懒加载路由,不能只看构建成功。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- WebGL 的最终可用性还受显卡、远程桌面和驱动影响;无 WebGL 环境应走现有的二维或文本信息回退。
|
||||
- `1366×768`、`1920×1080`、`2560×1440` 的精确视觉截图需要对应大小的真实显示环境。当前自动化会读取真实 DOM 尺寸和样式,但不会伪造操作系统无法提供的物理视口。
|
||||
- 创建、删除、发布、回滚等会修改业务数据的管理操作不在自动点击矩阵中;这些页面会验证加载、可见 Tabs 与未捕获异常,数据写入流程由专门测试数据验收。
|
||||
@@ -0,0 +1,56 @@
|
||||
# Firefox 52 路由与交互验收说明
|
||||
|
||||
## 自动化范围
|
||||
|
||||
`pnpm smoke:firefox52:full` 直接连接 Firefox 52 的远程调试端口,并以管理员会话逐一访问前端路由。矩阵当前包含 118 个入口,覆盖:
|
||||
|
||||
- 登录、落地页、公开页和独立会商页;
|
||||
- 工作区、智能体、技能、AI 写作、深度研究、公文、企业研究和 Notebook;
|
||||
- 多智能体会商、业务链、报告协作和知识空间;
|
||||
- 定时任务、管理员页面、设置页面和模型相关页面;
|
||||
- 任务管理、任务分析、行动分析、任务会商、资源管理和轻应用;
|
||||
- canvas、notebook、strategy、iframe 与兼容重定向入口。
|
||||
|
||||
每条路由都会检查 URL/重定向、页面完整加载、根节点尺寸、可见内容、错误边界、未捕获 `PageError`、控制台错误和 chunk 加载错误。页面内所有可见且未禁用的 `[role="tab"]` 会依次点击,以触发非破坏性的懒加载面板。脚本不会点击保存、删除、发布、回滚等会修改业务数据的按钮。
|
||||
|
||||
默认使用应用正常的 SPA 导航。`FIREFOX_FULL_RELOAD=1` 可切换为逐路由整页刷新,建议配合 `FIREFOX_ROUTE_FILTER` 分批运行。Firefox 52 在远程调试器、开发 source map 和上百次连续整页刷新同时存在时会积累调试内存;默认模式可以覆盖全部入口,同时避免把调试器内存压力误判为某个页面故障。
|
||||
|
||||
## 核心交互与专项
|
||||
|
||||
`pnpm smoke:firefox52` 当前包含 20 项真实浏览器检查:
|
||||
|
||||
- 7 个核心页面加载;
|
||||
- 左下角“设置和更多”、换肤色板;
|
||||
- Agent、模型和常用提示词弹窗;
|
||||
- 输入框 Enter 提交;
|
||||
- 对话类型筛选、项目创建模式;
|
||||
- AI 写作模型、会商业务链、定时任务新建弹窗;
|
||||
- Markdown 编辑器重置;
|
||||
- 调用正式后端接口生成非空 DOCX Blob,并校验 MIME 与 `.docx` 文件名。
|
||||
|
||||
`pnpm smoke:firefox52:live` 在上述基础上执行一轮真实问答。它验证表单提交、线程创建、路由跳转、XHR 增量流式读取和包含唯一校验标记的助手回复。`pnpm smoke:firefox52:themes` 验证六套主题乘以全量/简洁布局,共 12 种组合。
|
||||
|
||||
## 运行方式
|
||||
|
||||
```powershell
|
||||
$env:FIREFOX_DEBUG_PORT = "6001"
|
||||
pnpm smoke:firefox52
|
||||
pnpm smoke:firefox52:live
|
||||
pnpm smoke:firefox52:themes
|
||||
pnpm smoke:firefox52:full
|
||||
```
|
||||
|
||||
可用 `FIREFOX_ROUTE_FILTER` 只回归匹配的路由;冷编译较重的页面可通过 `FIREFOX_ROUTE_TIMEOUT_MS` 调整单页等待时间,默认 30 秒。测试账号默认 `admin`,可用 `FIREFOX_TEST_USERNAME` 和 `FIREFOX_TEST_PASSWORD` 覆盖。
|
||||
|
||||
## 2026-10-09 结果
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| 核心页面与交互 | 20/20 通过 |
|
||||
| 真实流式问答 | 1/1 通过 |
|
||||
| 真实智能体流式问答 | 1/1 通过 |
|
||||
| 主题 × 布局 | 12/12 通过 |
|
||||
| 开发站点完整路由 | 118/118 通过,并点击 71 个可见非破坏性 Tabs |
|
||||
| 生产预览完整路由 | 118/118 通过,并点击 74 个可见非破坏性 Tabs |
|
||||
|
||||
验收结论仅覆盖本次账号和现有测试数据。需要特定外部系统、任务深链参数、硬件 WebGL 或实际业务数据写入的流程,仍应在对应部署环境补充人工验收。
|
||||
@@ -0,0 +1,579 @@
|
||||
# Firefox 52 全功能兼容与 Rspack 高性能构建开发计划
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 目标分支 | `fix/firefox52-compat` |
|
||||
| 独立工作区 | `F:\react01\firefox-web` |
|
||||
| 前端目录 | `F:\react01\firefox-web\frontend-web` |
|
||||
| 目标浏览器 | Firefox 52(Windows x64) |
|
||||
| 技术方向 | React 18.3 + Tailwind CSS 3.4 + Rspack 2.x + 内置 SWC |
|
||||
| 核心目标 | 尽可能保持现有全部功能,提高冷构建和增量构建速度,保证主要页面与主题样式 |
|
||||
| 当前状态 | 兼容构建与自动回归已接通:核心交互 20/20、主题布局 12/12、完整路由 118/118,真实普通问答与智能体问答均通过 |
|
||||
|
||||
### 1.1 2026-10-09 实施进度
|
||||
|
||||
已完成:
|
||||
|
||||
- 建立 Firefox 52 专用 Rspack/SWC 单目标构建,开发服务器、快速构建、正式构建和预览命令均已接通;持久化缓存生效。当前机器实测冷生产构建 16.865 秒,首轮后的四次稳态构建中位数 1.584 秒、P95 1.641 秒。
|
||||
- 运行时降级到 React 18.3;Tailwind CSS 采用 `feat/tailwind-v3-legacy-chrome` 已验证方案降级到 3.4,保留现有主题 token 和组件类名。
|
||||
- 建立统一兼容入口,补齐 Pointer Events、`requestSubmit`、`scrollIntoView({block:"nearest"})`、Blob、UUID、Clipboard、ResizeObserver、Web Streams 等能力。
|
||||
- 流式请求使用 XHR `onprogress` 适配 SSE/NDJSON/Range 等接口;单元测试 4/4 通过。
|
||||
- 修复 Firefox 52 下的 SPA 路由白屏、`display: contents` 根布局失效、侧边栏分隔线颜色、首页宽度 token、欢迎标题透明、常用提示词换行、Radix 菜单无法点击、cmdk 下拉框崩溃和 Enter 无法发送。
|
||||
- CSS 兼容样式拆到 `src/styles/firefox52/`;PostCSS 自动转换 `rgb(var(...) / alpha)`、`:where()` 和暗色后代选择器,并为 flex gap 提供回退。
|
||||
- MapLibre 固定到 4.7.1,xlsx 解压改用 fflate;对 LangChain/LangGraph/LangSmith、Oniguruma、React Router、Zod 等依赖启用精确转换或源码兼容 loader。
|
||||
- 修复 LangGraph 普通 async 方法中 `for await` 被错误降为同步迭代的问题,并增加 loader 回归测试;修复 XYFlow 12.12 的 BigInt 渲染序号导致流程页面白屏的问题。
|
||||
- 增加 `check:firefox52-css`、`check:firefox52-js`、核心交互、真实流式、主题布局和 118 条完整路由脚本,用于阻止现代 CSS/JS 语法、路由和页面交互回归。
|
||||
- 建立 `docs/firefox52-dependency-compatibility.md` 和 `docs/firefox52-route-compatibility.md`,记录版本、补丁原因、验证入口与平台限制。
|
||||
|
||||
本阶段验证结果:
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|---|---|
|
||||
| Firefox 52 核心页面与交互冒烟 | 20/20 通过 |
|
||||
| Firefox 52 真实普通问答 | 线程创建、路由跳转、XHR 增量流与最终回复均通过 |
|
||||
| Firefox 52 真实智能体问答 | 自动选择可用智能体,线程创建、XHR 增量流与最终回复均通过 |
|
||||
| 主题与布局 | 6 套主题 × 全量/简洁布局,12/12 通过 |
|
||||
| Word 导出 | 后端实际生成非空 DOCX,MIME 与下载文件名通过 |
|
||||
| 完整路由 | 开发与生产预览均为 118/118;分别点击 71/74 个可见非破坏性 Tabs;强制刷新模式支持按过滤器分批运行 |
|
||||
| 关键交互 | 设置和更多、换肤、Agent/模型选择、提示词管理、Enter 提交、项目创建、业务链条、定时任务新建、Markdown 编辑器与 Word 下载 |
|
||||
| 正式产物 CSS 检查 | 通过 |
|
||||
| 正式产物 Acorn ES2017 解析 | 464/464,通过 |
|
||||
| Firefox 52 浏览器自身 `new Function` 解析 | 464/464,0 错误 |
|
||||
| 正式产物 JS 体积 | 25.2 MiB(464 个按需 chunk) |
|
||||
| Rspack 生产构建 | 冷构建 16.865 秒;稳态中位数 1.584 秒、P95 1.641 秒 |
|
||||
|
||||
管理员、任务、iframe、公开页、canvas、会商和设置等静态入口已纳入完整路由矩阵。需要特定任务参数、外部系统、业务数据写入、硬件 WebGL 或精确物理分辨率的流程,仍需在对应部署环境补充人工验收,不能用静态入口通过替代。
|
||||
|
||||
## 2. 建设目标
|
||||
|
||||
本项目在独立兼容分支中建设 Firefox 52 专用前端构建链。现代浏览器版本继续在主开发目录使用现有 Vite 构建,兼容分支只生成 Firefox 52 单目标产物,不生成现代与旧版两套 bundle。
|
||||
|
||||
开发遵循以下目标:
|
||||
|
||||
1. **功能覆盖优先**:现有路由和主要业务流程都必须进入兼容验收矩阵,不能以“首页能够打开”作为完成标准。
|
||||
2. **构建性能优先**:使用 Rspack、SWC、增量编译和持久化缓存,移除 legacy bundle 的二次 Babel/SystemJS 转换。
|
||||
3. **样式一致性优先**:保留 Tailwind、TDesign、Radix 和自定义主题,系统处理 Firefox 52 不支持的 CSS 语法与布局能力。
|
||||
4. **兼容逻辑可维护**:浏览器差异集中到构建配置、兼容入口、传输适配器和依赖补丁,不在业务组件中散落版本判断。
|
||||
5. **持续跟进主分支**:兼容分支能够定期合并 `master-zg` 的新功能,并通过自动检查发现新增的不兼容依赖和语法。
|
||||
|
||||
## 3. 完成标准
|
||||
|
||||
### 3.1 功能标准
|
||||
|
||||
- Firefox 52 打开登录页、首页和工作区时没有白屏。
|
||||
- 首屏与路由切换没有未捕获 JavaScript 异常、语法错误和 chunk 加载错误。
|
||||
- 功能矩阵中的 P0、P1 场景全部通过;P2 场景逐项验证并记录结果。
|
||||
- 无法由旧浏览器实现的原生能力必须提供等价回退,页面和主流程不能直接不可用。
|
||||
- 文件上传、文件下载、流式问答、Markdown、公式、代码块、流程图和 Word 导出必须作为专项验收项。
|
||||
- iframe、任务深链、公开分享页面和运行时配置必须保持现有 URL 与接口行为。
|
||||
|
||||
### 3.2 性能标准
|
||||
|
||||
先在阶段 0 获取当前 Vite legacy 的基线,后续使用相同机器、相同源码、相同依赖和相同环境重复测试。最终门槛如下:
|
||||
|
||||
| 指标 | 目标 |
|
||||
|---|---|
|
||||
| 开发服务器首次可访问时间 | 不高于基线的 50%,期望不超过 10 秒 |
|
||||
| 修改单个 TSX 后的增量构建 | P95 不超过 1.5 秒 |
|
||||
| 修改全局 CSS 后的增量构建 | P95 不超过 2 秒 |
|
||||
| 无缓存生产构建 | 不高于基线的 50% |
|
||||
| 有缓存生产构建 | 不高于基线的 30% |
|
||||
| 连续构建稳定性 | 连续 10 次无随机失败、无失效 chunk、无内存溢出 |
|
||||
| 产物浏览器检查 | 不含 Firefox 52 无法解析的模块入口和已知危险语法 |
|
||||
|
||||
绝对时间与相对提升发生冲突时,以“功能正确、样式正确、相对基线明显提升”为判断顺序。未达到性能门槛前不移除旧构建链。
|
||||
|
||||
### 3.3 样式标准
|
||||
|
||||
- 全量模式、简洁模式、嵌入模式均无整体布局塌陷。
|
||||
- 浅色、深色、浅蓝、浅绿、冰蓝、浅黄等现有主题逐一验证。
|
||||
- 重点分辨率至少覆盖 `1366×768`、`1920×1080`、`2560×1440`。
|
||||
- 导航栏、侧边栏、表单、表格、标签页、弹窗、下拉框、Tooltip、抽屉和浮层没有明显错位或遮挡。
|
||||
- 页面不得出现因 `@layer`、现代颜色语法或不支持的选择器导致的整片无样式。
|
||||
- 主流程截图与现代浏览器参考图保持结构、间距、颜色和层级一致;字体抗锯齿造成的细微像素差异不作为失败。
|
||||
|
||||
## 4. 当前兼容基线
|
||||
|
||||
兼容分支已经存在以下措施,Rspack 迁移必须先等价承接,再进行优化:
|
||||
|
||||
1. `@vitejs/plugin-legacy` 生成 Firefox 52 的 SystemJS legacy bundle。
|
||||
2. Mermaid legacy chunk 额外经过 Babel 转换。
|
||||
3. 对 `p-queue`、GFM autolink、Oniguruma parser、LangSmith XXHash 进行构建期源码补丁。
|
||||
4. 入口安装 AbortController、IntersectionObserver、ResizeObserver 和 Web Streams polyfill。
|
||||
5. PostCSS 展平 Tailwind `@layer`。
|
||||
6. 自定义颜色回退和 Lightning CSS 目标转换。
|
||||
7. 全局样式中已经维护主题对应的 legacy 色值。
|
||||
8. `index.html` 在应用入口前安装 `AbortSignal.timeout` 回退。
|
||||
|
||||
当前方案的主要问题:
|
||||
|
||||
- Vite 开发服务器依赖现代 ESM,Firefox 52 无法直接用于日常开发调试。
|
||||
- legacy 构建对最终 chunks 再执行 Babel 和 SystemJS 转换,模块量越大耗时越明显。
|
||||
- 依赖源码在构建过程中做字符串替换,缓存命中率、升级稳定性和错误定位都较差。
|
||||
- Web Streams polyfill只能补充 JavaScript API,不能自动让 Firefox 52 的 `fetch` 响应具备网络流式读取能力。
|
||||
- CSS 已有回退主要围绕旧版 Chrome 建设,还需要按 Firefox 52 实际渲染能力重新审计。
|
||||
|
||||
## 5. 总体技术方案
|
||||
|
||||
### 5.1 构建链
|
||||
|
||||
Firefox 52 专用构建使用以下组件:
|
||||
|
||||
- `@rspack/core`
|
||||
- `@rspack/cli`
|
||||
- `@rspack/dev-server`
|
||||
- `builtin:swc-loader`
|
||||
- Rspack SWC JavaScript minimizer
|
||||
- Rspack Lightning CSS minimizer或现有 PostCSS + Lightning CSS 流程
|
||||
- HTML 生成插件、资源复制插件和 React Refresh 集成(确认 Firefox 52 可运行后启用)
|
||||
|
||||
构建目标分成两层:
|
||||
|
||||
1. Rspack runtime 目标指定 Firefox 52,保证 chunk loader、bootstrap 和 HMR runtime 不输出危险语法。
|
||||
2. SWC `env.targets` 指定 Firefox 52,负责 TS、TSX、JSX 和业务 JavaScript 的语法降级及 core-js 注入。
|
||||
|
||||
生产构建仅输出一套 Firefox 52 产物,使用普通 script/JSONP chunk loading,不保留 Vite legacy 的 SystemJS 双阶段处理。
|
||||
|
||||
### 5.2 配置与资源保持
|
||||
|
||||
迁移时保持以下外部行为:
|
||||
|
||||
- `runtime-config.js` 继续在应用 bundle 之前加载。
|
||||
- 保持现有 `VITE_*` 配置键,不要求部署环境一次性改名。
|
||||
- `@/env`、`@/*`、`@vite/*`、`next/navigation`、`next/link` 等 alias 保持一致。
|
||||
- `base`、静态资源目录、favicon、字体和图片路径保持可配置。
|
||||
- Hash 路由、BrowserRouter 深层路由、iframe 嵌入路由和公开页面刷新都能正确加载资源。
|
||||
- 开发端口与启动脚本可以通过环境变量覆盖,默认不与主开发工作区冲突。
|
||||
|
||||
### 5.3 构建模式
|
||||
|
||||
计划提供以下命令:
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `npm run dev` / `pnpm dev` | 默认启动 Firefox 52 Rspack Dev Server,端口 5174 |
|
||||
| `pnpm dev:firefox52` | Rspack Dev Server,供 Firefox 52 直接调试 |
|
||||
| `npm run dev:vite` / `pnpm dev:vite` | 仅在现代浏览器调试时启动原 Vite Dev Server |
|
||||
| `pnpm build:firefox52` | Firefox 52 正式单目标构建 |
|
||||
| `pnpm build:firefox52:fast` | 不压缩、保留调试信息的快速验证构建 |
|
||||
| `pnpm preview:firefox52` | 使用静态服务器验证正式产物 |
|
||||
| `pnpm check:firefox52` | 扫描危险语法、资源路径和兼容清单 |
|
||||
| `pnpm benchmark:firefox52` | 执行冷构建、热构建和增量构建基准 |
|
||||
| `pnpm smoke:firefox52` | 运行 Firefox 52 核心页面与交互冒烟 |
|
||||
| `pnpm smoke:firefox52:live` | 运行真实普通问答流式冒烟 |
|
||||
| `pnpm smoke:firefox52:agent-live` | 选择当前可用智能体并运行真实流式冒烟 |
|
||||
| `pnpm smoke:firefox52:themes` | 验证 6 套主题 × 2 种布局 |
|
||||
| `pnpm smoke:firefox52:full` | 验证 118 个路由及可见非破坏性 Tabs |
|
||||
|
||||
类型检查继续单独运行 `pnpm typecheck`,不阻塞开发构建的模块转换。
|
||||
|
||||
## 6. JavaScript 与浏览器 API 兼容方案
|
||||
|
||||
### 6.1 兼容入口
|
||||
|
||||
建立唯一的 Firefox 52 兼容入口,并保证它早于 React、路由、请求客户端和业务模块执行。兼容入口统一处理:
|
||||
|
||||
- `Promise`、`Map`、`Set`、`Symbol`、迭代器及必要的 core-js 能力。
|
||||
- AbortController、AbortSignal.timeout 和 abort reason 差异。
|
||||
- ResizeObserver、IntersectionObserver、matchMedia 监听接口。
|
||||
- ReadableStream、WritableStream、TransformStream。
|
||||
- `queueMicrotask`、`globalThis`、`Object.fromEntries`、`Promise.finally` 等运行时能力。
|
||||
- `crypto.randomUUID`、Clipboard、下载、全屏等能力的回退实现。
|
||||
- `requestIdleCallback`、`structuredClone` 等组件可能间接使用的 API。
|
||||
|
||||
所有 polyfill 必须具备能力检测,只在缺失时安装,避免重复覆盖浏览器实现。
|
||||
|
||||
### 6.2 流式请求专项
|
||||
|
||||
流式问答是兼容工作的 P0 项。Firefox 52 的网络层不能依赖现代 `fetch().body.getReader()`,需建立统一传输适配器:
|
||||
|
||||
1. 现代浏览器继续使用现有 fetch/SSE/NDJSON 路径。
|
||||
2. Firefox 52 使用 XHR `onprogress` 增量读取 `responseText`。
|
||||
3. 适配器统一处理 SSE、NDJSON、取消、超时、断线、服务端错误和 UTF-8 分片。
|
||||
4. LangGraph、普通问答、智能体问答、AI 写作、深度研究、会商、技能地址扫描等流式接口全部复用同一兼容层。
|
||||
5. 增加分片边界测试,覆盖半个 JSON、半个 UTF-8 字符、连续空行、最后一行无换行和中途取消。
|
||||
|
||||
### 6.3 不可直接转换的语法
|
||||
|
||||
以下内容不能只依赖 SWC 或 core-js,必须通过升级、降级、替代或固定补丁解决:
|
||||
|
||||
- BigInt 字面量及 BigInt 专用算法。
|
||||
- 正则向后断言、Unicode property escapes 和不支持的正则 flag。
|
||||
- Module Worker 和依赖原生 ESM 的 Worker。
|
||||
- 运行时拼接后才生成的现代语法。
|
||||
- 强依赖新浏览器 Web API 的第三方组件。
|
||||
|
||||
处理顺序:优先选择兼容版本,其次维护专用 fork,再使用 `patch-package` 或安装后补丁。构建期间的逐文件字符串替换只作为临时迁移手段。
|
||||
|
||||
### 6.4 产物静态扫描
|
||||
|
||||
`check:firefox52` 至少扫描:
|
||||
|
||||
- 未被打包的 `import`、`export`、`import.meta`。
|
||||
- BigInt 字面量和 `BigInt64Array`/`BigUint64Array` 直接依赖。
|
||||
- 正则向后断言、Unicode property escapes、dotAll 等危险正则。
|
||||
- 未处理的 async generator、module worker 和 module script。
|
||||
- 绝对开发机路径、丢失的 chunk、错误 public path。
|
||||
- HTML 中会被 Firefox 52 忽略且没有回退的入口脚本。
|
||||
|
||||
扫描结果必须支持白名单,白名单需要注明依赖、原因、验证方式和负责人,不能静默忽略。
|
||||
|
||||
## 7. 第三方依赖治理
|
||||
|
||||
### 7.1 依赖分级
|
||||
|
||||
建立 `docs/firefox52-dependency-compatibility.md`,按以下类别维护:
|
||||
|
||||
| 等级 | 含义 | 处理方式 |
|
||||
|---|---|---|
|
||||
| A | 原生兼容或经 SWC 即可兼容 | 正常打包 |
|
||||
| B | 缺少 API polyfill | 加入统一兼容入口 |
|
||||
| C | 包含可转换的新语法 | 加入 SWC 依赖 allowlist |
|
||||
| D | 包含不可转换语法或新 API | 固定版本、fork、patch 或替换 |
|
||||
| E | Firefox 52 平台能力无法完整支持 | 提供业务等价回退并记录差异 |
|
||||
|
||||
### 7.2 首批专项依赖
|
||||
|
||||
优先审计以下依赖:
|
||||
|
||||
- React 19、React DOM、React Router。
|
||||
- LangGraph SDK、LangChain、AI SDK、LangSmith。
|
||||
- Mermaid、Shiki、react-syntax-highlighter、Oniguruma。
|
||||
- CodeMirror、Tiptap/ProseMirror、KaTeX、Marked、react-markdown。
|
||||
- MapLibre GL、Leaflet、XYFlow、OGL、GSAP、Motion。
|
||||
- Radix UI、TDesign React、cmdk、Embla。
|
||||
- TanStack Query、Axios、Zod、date-fns、uuid。
|
||||
- docx、file-saver 和文件预览相关模块。
|
||||
|
||||
重型模块保持路由级或组件级动态加载。Rspack 必须把动态 import 转为 Firefox 52 可用的普通 chunk loader。
|
||||
|
||||
## 8. CSS 与视觉一致性方案
|
||||
|
||||
### 8.1 CSS 构建顺序
|
||||
|
||||
CSS 建议按以下顺序处理:
|
||||
|
||||
1. Tailwind 4 生成样式。
|
||||
2. 展平 `@layer`,防止 Firefox 52 忽略整组规则。
|
||||
3. 生成颜色和透明度回退。
|
||||
4. 处理嵌套、逻辑属性、选择器和可安全转换的现代语法。
|
||||
5. Lightning CSS 按 Firefox 52 目标转换。
|
||||
6. 生产模式压缩,开发模式保持未压缩和快速 source map。
|
||||
|
||||
需重点验证当前 `rgb(var(--legacy-*-rgb) / alpha)` 在 Firefox 52 中的实际表现;若浏览器不接受空格分隔和斜杠透明度语法,应在构建期生成逗号分隔的 `rgba()` 或直接颜色值。
|
||||
|
||||
### 8.2 手写兼容 CSS 与文件拆分
|
||||
|
||||
构建工具无法可靠转换,或者转换后仍存在布局和交互差异的样式,直接编写 Firefox 52 专用 CSS。兼容样式不得继续集中追加到 `globals.css`,统一放在独立目录中按职责拆分:
|
||||
|
||||
```text
|
||||
src/styles/firefox52/
|
||||
├── index.css # 只负责按固定顺序汇总核心兼容样式
|
||||
├── tokens.css # 颜色、透明度、阴影和主题变量回退
|
||||
├── base.css # 浏览器基础差异和通用元素修正
|
||||
├── layout.css # 页面框架、侧边栏、滚动区和响应式布局
|
||||
├── overlays.css # Dialog、Popover、Select、Tooltip、Guide 等浮层
|
||||
├── markdown.css # Markdown、代码块、公式、表格和引用
|
||||
├── editors.css # CodeMirror、Tiptap、ProseMirror 等编辑器
|
||||
├── visualizations.css # Mermaid、XYFlow、图表、地图和 Canvas 容器
|
||||
├── chat.css # 普通问答、智能体问答和消息时间线
|
||||
├── writing-research.css # AI 写作、深度研究和公文工作台
|
||||
├── roundtable.css # 会商、流程图和报告协作
|
||||
├── task-workspaces.css # 任务深链、目的树和快捷跳转栏
|
||||
└── admin.css # 管理端表格、表单和配置页
|
||||
```
|
||||
|
||||
拆分规则:
|
||||
|
||||
1. 全局首屏必需的 `tokens/base/layout/overlays` 由 `index.css` 统一引入。
|
||||
2. Markdown、编辑器、可视化、会商、任务工作区和管理端样式随对应路由或组件导入,让 Rspack 生成独立 CSS chunk,避免所有用户加载完整兼容样式。
|
||||
3. 单个兼容 CSS 文件建议控制在 500 行或未压缩 30 KB 以内;超过任一门槛时继续按组件或业务域拆分。
|
||||
4. HTML 根节点由兼容入口设置稳定标记,例如 `data-browser-compat="firefox52"`。兼容规则应挂在该标记下,防止影响现代浏览器和公共组件的默认样式。
|
||||
5. 优先复用语义 token 和现有组件 class,不复制整份第三方组件样式。只覆盖 Firefox 52 实际不兼容的声明。
|
||||
6. `!important` 只用于第三方组件无法通过正常层叠覆盖的场景,并在规则旁注明原样式来源和原因。
|
||||
7. 文件加载顺序必须固定:主题 token → 基础样式 → 布局 → 通用浮层 → 业务页面。禁止依赖偶然的 chunk 到达顺序解决优先级。
|
||||
8. 每个文件明确对应验收页面,删除页面或修复上游兼容性时同步清理失效规则。
|
||||
|
||||
如果某个功能仅需要少量兼容规则,样式文件放在功能模块旁并使用 `.firefox52.css` 后缀;跨页面共用的兼容规则才放入 `src/styles/firefox52/`。构建检查需要统计各文件行数、体积、重复选择器和 `!important` 数量,超过门槛时提示拆分。
|
||||
|
||||
### 8.3 CSS 风险清单
|
||||
|
||||
建立自动扫描和人工验证清单,覆盖:
|
||||
|
||||
- `color-mix()`、OKLCH、现代 `rgb()`/`hsl()`。
|
||||
- `@layer`、CSS nesting、container query。
|
||||
- `:has()`、`:focus-visible`、复杂 `:is()`/`:where()`。
|
||||
- Flexbox `gap`、Grid 差异和 `subgrid`。
|
||||
- `aspect-ratio`、`inset`、`min()`、`max()`、`clamp()`。
|
||||
- `dvh`/`svh`/`lvh`、逻辑方向属性。
|
||||
- `backdrop-filter`、mask、混合模式和复杂滤镜。
|
||||
- sticky、滚动容器、overscroll 和自定义滚动条。
|
||||
- Portal 浮层、z-index、transform containing block 和 `pointer-events`。
|
||||
|
||||
无法可靠转换的布局功能通过兼容 utility 或组件级基础样式回退解决,不能只隐藏出错区域。
|
||||
|
||||
### 8.4 视觉回归范围
|
||||
|
||||
每种主题至少截取以下代表页面:
|
||||
|
||||
1. 登录页和新对话落地页。
|
||||
2. 普通问答流式消息页。
|
||||
3. 智能体列表、创建页和智能体聊天页。
|
||||
4. AI 写作工作台。
|
||||
5. 多智能体会商三步工作台和流程图。
|
||||
6. 知识库列表、编辑器和知识图谱。
|
||||
7. 任务管理、目的树弹窗和快捷跳转栏。
|
||||
8. 管理端表格、Tabs、表单和确认弹窗。
|
||||
9. iframe 嵌入页和公开分享页。
|
||||
10. 站点预览、地图、图表和全屏场景。
|
||||
|
||||
视觉验收重点比较布局边界、间距、颜色、滚动区域、遮罩和弹层位置。截图自动化优先使用与 Firefox 52 匹配的 Selenium/geckodriver;无法稳定自动化的页面纳入人工截图清单。
|
||||
|
||||
## 9. 全功能验收矩阵
|
||||
|
||||
### 9.1 P0:必须首先打通
|
||||
|
||||
| 功能域 | 核心场景 |
|
||||
|---|---|
|
||||
| 登录与鉴权 | 密码登录、URL 参数登录、退出、刷新恢复、鉴权失效 |
|
||||
| 页面框架 | 全量/简洁模式、侧边栏、菜单、主题、路由刷新 |
|
||||
| 普通问答 | 新建会话、历史会话、流式回答、停止、重试、错误显示 |
|
||||
| 消息展示 | Markdown、GFM、代码高亮、公式、链接、引用、流程图 |
|
||||
| 文件 | 上传、附件卡片、产物预览、下载、Word 导出 |
|
||||
| 智能体问答 | 智能体列表、进入聊天、新建和历史会话 |
|
||||
| iframe | `embed=1`、`hideJump=1`、主题、独立鉴权、postMessage |
|
||||
| 错误恢复 | 断网、接口 401/403/500、chunk 加载失败、刷新恢复 |
|
||||
|
||||
### 9.2 P1:主要业务能力
|
||||
|
||||
| 功能域 | 核心场景 |
|
||||
|---|---|
|
||||
| 智能体管理 | 创建、编辑、模型、知识空间、SOUL、技能、保存、引导 |
|
||||
| 舆情分析智能体 | 外部流式接口、会话、错误信息 |
|
||||
| AI 写作 | 经典版、对话版、阶段推进、干预、资料、Markdown 与 Word 下载 |
|
||||
| 深度研究/公文 | 发起任务、过程状态、恢复、结果展示和下载 |
|
||||
| 多智能体会商 | 普通、规划、8BF、岗位会商、链条配置、后台续跑 |
|
||||
| 报告协作 | 会话列表、协作过程、结果编辑和导出 |
|
||||
| 知识空间 | 列表、详情、上传、Markdown 编辑、Wiki、图谱、检索调试 |
|
||||
| Notebook | 列表、详情、资料和生成流程 |
|
||||
| 定时任务 | 任务列表、创建、运行详情、公开运行页 |
|
||||
| 任务深链 | rwfx、3qfx、xdfx、agentfx、单智能体、会商、行动分区 |
|
||||
| 任务报告 | 生成、文件卡片、入库确认、宿主通知 |
|
||||
| 轻应用 | 应用列表、窗口打开、iframe 宿主、动态菜单 |
|
||||
|
||||
### 9.3 P2:管理、配置和扩展能力
|
||||
|
||||
- 用户、活动、记忆、模型、模型交换与并发配置。
|
||||
- 智能体、技能、技能上下线、技能地址扫描/预览/替换/回滚。
|
||||
- 菜单、简洁菜单、按钮、标签、固定问题、推荐问题、提示词前缀。
|
||||
- 敏感词、排行榜、插件、岗位和岗位角色。
|
||||
- 业务链、发布广场、私有广场、技能压缩、LLM 指标和 checkpoint 迁移。
|
||||
- 公开分享、排行榜、会商报告、会商看板和定时任务全屏页面。
|
||||
- Web App/Site Studio、地图、流程图、图表和沙箱 HTML 预览。
|
||||
- 微信样式入口、无界嵌入和其他特殊页面壳。
|
||||
|
||||
### 9.4 单项验收规则
|
||||
|
||||
每个功能项必须记录:
|
||||
|
||||
- 入口 URL、角色和前置数据。
|
||||
- 页面是否正常加载及首个可交互时间。
|
||||
- 主操作、保存/提交、返回和刷新恢复结果。
|
||||
- 是否涉及流式、上传、下载、Worker、Canvas、SVG、WebGL 或 iframe。
|
||||
- Firefox 52 控制台错误和网络失败。
|
||||
- 样式截图、已知差异和回退行为。
|
||||
- 验收人、日期、构建 commit 和结果。
|
||||
|
||||
路由清单应由路由源码自动提取后与人工场景表合并,新增路由若未进入矩阵则 CI 提示失败。
|
||||
|
||||
## 10. 开发阶段
|
||||
|
||||
### 阶段 0:基线和可观测性
|
||||
|
||||
工作项:
|
||||
|
||||
- 固定 Node、pnpm、Firefox 52、Rspack/SWC 候选版本和测试机器信息。
|
||||
- 记录 Vite legacy 的冷构建、热构建、增量构建、内存峰值和产物体积。
|
||||
- 保存当前主要页面在现代浏览器和 Firefox 52 的截图、控制台错误及网络日志。
|
||||
- 自动提取路由清单和动态 import 清单。
|
||||
- 输出依赖语法扫描报告和浏览器 API 使用清单。
|
||||
- 建立可重复执行的 benchmark 脚本,结果保存为 JSON 和 Markdown。
|
||||
|
||||
退出条件:任何人都能按文档复现当前构建时间和 Firefox 52 白屏/错误现状。
|
||||
|
||||
### 阶段 1:Rspack 并行构建骨架
|
||||
|
||||
工作项:
|
||||
|
||||
- 增加 Rspack 配置、HTML、静态资源、alias、环境变量和 public path。
|
||||
- 接入内置 SWC,先处理项目源码,再逐步扩展到依赖 allowlist。
|
||||
- 接入 React、CSS、字体、SVG、图片和动态 import。
|
||||
- 建立 `dev:firefox52`、`build:firefox52:fast` 和 preview 命令。
|
||||
- 暂时保留 Vite legacy 命令作为对照和回退。
|
||||
|
||||
退出条件:Firefox 52 能通过 Rspack dev server 打开登录页和空工作区,没有语法错误与 chunk 加载错误。
|
||||
|
||||
### 阶段 2:运行时与流式链路
|
||||
|
||||
工作项:
|
||||
|
||||
- 重整统一兼容入口和 polyfill 顺序。
|
||||
- 完成 XHR 流式适配器并接入核心请求路径。
|
||||
- 验证取消、超时、错误解析、鉴权头和跨域行为。
|
||||
- 处理 Web Worker、WebSocket、EventSource 和动态 chunk 加载。
|
||||
- 完成普通问答端到端测试。
|
||||
|
||||
退出条件:普通问答可以连续完成发送、流式接收、停止、重试和刷新恢复。
|
||||
|
||||
### 阶段 3:依赖清理和固定补丁
|
||||
|
||||
工作项:
|
||||
|
||||
- 对首批专项依赖分级。
|
||||
- 把现有 Vite 字符串补丁迁移为版本固定、fork、alias 或安装后补丁。
|
||||
- 对 Mermaid、Shiki、Oniguruma、LangSmith、p-queue 等建立单独验收用例。
|
||||
- 配置最终产物危险语法扫描。
|
||||
- 为新增依赖增加 CI 扫描。
|
||||
|
||||
退出条件:生产产物通过静态扫描,补丁均有版本校验,依赖升级导致补丁失效时构建直接失败并给出清晰错误。
|
||||
|
||||
### 阶段 4:CSS 和组件样式兼容
|
||||
|
||||
工作项:
|
||||
|
||||
- 把现有 PostCSS/Tailwind/Lightning CSS 流程接入 Rspack。
|
||||
- 建立 `src/styles/firefox52/`,按主题、布局、浮层和业务域拆分手写兼容 CSS,避免继续扩大 `globals.css`。
|
||||
- 配置核心兼容 CSS 与路由级兼容 CSS 的分包策略,并增加单文件行数、体积和重复规则检查。
|
||||
- 修复主题颜色、透明度、cascade layer、flex gap 和现代选择器问题。
|
||||
- 验证 TDesign、Radix、Tailwind 组件和 Portal 浮层。
|
||||
- 建立 CSS 危险语法扫描与代表页面截图回归。
|
||||
- 验证各主题和三种目标分辨率。
|
||||
|
||||
退出条件:P0 页面和代表组件没有布局塌陷、透明色失效、弹层错位或滚动锁死。
|
||||
|
||||
### 阶段 5:全功能逐批清零
|
||||
|
||||
按以下批次执行,每批都完成“功能、控制台、网络、样式、刷新恢复”五项检查:
|
||||
|
||||
1. 登录、框架、普通问答和文件。
|
||||
2. 智能体管理与智能体问答。
|
||||
3. AI 写作、深度研究、公文。
|
||||
4. 多智能体会商、业务链和报告协作。
|
||||
5. 知识空间、Notebook、Markdown 编辑器和图谱。
|
||||
6. 任务深链、任务报告、轻应用和 iframe。
|
||||
7. 定时任务、管理端和设置页。
|
||||
8. 公开页面、Site Studio、地图、图表和其他扩展能力。
|
||||
|
||||
退出条件:P0、P1 全部通过,P2 完成验证;所有遗留项都有可执行的修复或等价回退方案。
|
||||
|
||||
### 阶段 6:构建性能专项优化
|
||||
|
||||
工作项:
|
||||
|
||||
- 开启并调优 Rspack 持久化缓存和增量构建。
|
||||
- 开发模式关闭压缩,使用低成本 source map。
|
||||
- 生产模式使用 SWC minimizer,CSS 使用 Lightning CSS minimizer。
|
||||
- 缩小 SWC 转换范围,对不兼容依赖使用精确 allowlist。
|
||||
- 对 Mermaid、Shiki、MapLibre、编辑器、会商等重型模块做路由级拆分。
|
||||
- 检查重复依赖、barrel export、无效 locale 和大资源。
|
||||
- 评估 lazy compilation;仅在不破坏 Firefox 52 chunk 加载时启用。
|
||||
- 将耗时阶段输出到构建 profile,逐项消除长任务。
|
||||
|
||||
退出条件:达到第 3.2 节性能门槛,同时功能和样式回归无新增失败。
|
||||
|
||||
### 阶段 7:持续集成和离线部署衔接
|
||||
|
||||
工作项:
|
||||
|
||||
- CI 执行 typecheck、Rspack 构建、危险语法扫描、核心单测和冒烟测试。
|
||||
- 保存构建时间趋势,超过阈值时提示回归。
|
||||
- 固定包版本和校验值,保证离线环境可重复安装。
|
||||
- 离线依赖与发布资产交由 `knowledge-base-bj/deerflow-offline-deployment` 仓库维护,本业务仓库只保留源码和依赖清单。
|
||||
- 更新启动脚本、部署文档、缓存策略和静态资源响应头。
|
||||
|
||||
退出条件:干净环境可以离线安装、构建、启动并通过 Firefox 52 冒烟测试。
|
||||
|
||||
### 阶段 8:切换与旧链路清理
|
||||
|
||||
工作项:
|
||||
|
||||
- 完成最终功能矩阵、视觉报告和性能报告评审。
|
||||
- 将 Firefox 52 开发和构建命令切换到 Rspack。
|
||||
- 删除已经被固定补丁替代的 Vite legacy 临时代码。
|
||||
- 保留一段时间的 Vite 基准脚本,确认稳定后再彻底清理兼容分支中的旧依赖。
|
||||
|
||||
退出条件:连续多个提交未出现功能、样式和性能回归,Rspack 成为兼容分支唯一正式构建链。
|
||||
|
||||
## 11. 性能测试方法
|
||||
|
||||
每组测试至少执行 5 次,取中位数并记录 P95:
|
||||
|
||||
1. **冷启动**:删除构建缓存后启动开发服务器,测量到首页可请求时间。
|
||||
2. **热启动**:保留缓存重启开发服务器。
|
||||
3. **TSX 增量**:修改一个叶子组件和一个高层布局组件。
|
||||
4. **CSS 增量**:修改组件样式和全局主题样式。
|
||||
5. **冷生产构建**:删除 Rspack 缓存和 dist。
|
||||
6. **热生产构建**:保留缓存、源码不变再次构建。
|
||||
7. **单文件生产变更**:修改一个组件后重新构建。
|
||||
|
||||
记录字段:commit、构建模式、CPU、内存、磁盘、Node/pnpm 版本、总耗时、各阶段耗时、峰值内存、模块数量、chunk 数量、JS/CSS 体积和缓存命中率。
|
||||
|
||||
性能比较必须在相同机器和相同后台负载下进行。正式报告同时保留原始日志,避免只记录一次最佳结果。
|
||||
|
||||
## 12. 主分支同步策略
|
||||
|
||||
兼容分支需要持续接收正常业务开发,采用以下约束:
|
||||
|
||||
- 构建和兼容代码集中在独立目录与入口,减少对业务组件的侵入。
|
||||
- 能通过适配器解决的问题不直接修改业务协议。
|
||||
- 定期从 `master-zg` 合并新功能,每次合并后自动更新路由和依赖扫描结果。
|
||||
- 新增路由必须进入功能矩阵;新增依赖必须进入依赖兼容清单。
|
||||
- 新增流式接口必须使用统一传输层,不能绕过 Firefox 52 XHR 回退。
|
||||
- 新增主题和全局组件必须进入样式截图集。
|
||||
- 冲突解决优先保留主分支业务逻辑,再在兼容层补充旧浏览器处理。
|
||||
|
||||
## 13. 风险与处理原则
|
||||
|
||||
| 风险 | 处理原则 |
|
||||
|---|---|
|
||||
| 第三方依赖停止支持旧浏览器 | 固定版本或维护兼容 fork |
|
||||
| fetch 流式响应不可用 | XHR onprogress 统一回退 |
|
||||
| Map/WebGL 能力差异 | 降级依赖或提供保持业务信息的二维/静态回退 |
|
||||
| 编辑器、语法高亮和 Worker 依赖现代能力 | classic worker、主线程回退或兼容版本 |
|
||||
| CSS 转换后仍存在布局差异 | 兼容 utility、组件基础样式和截图回归 |
|
||||
| Polyfill 体积过大 | 单目标按需注入,重型功能路由级加载 |
|
||||
| 兼容补丁随依赖升级失效 | 精确版本校验,失败时终止构建 |
|
||||
| Rspack 插件兼容不足 | 优先使用内置能力,必要时编写小型 loader/plugin |
|
||||
| 主分支持续新增功能 | 自动路由、依赖、API 和 CSS 扫描 |
|
||||
| 旧浏览器自动化不稳定 | 自动冒烟与人工矩阵结合,保留截图和日志证据 |
|
||||
|
||||
## 14. 交付物
|
||||
|
||||
实施完成后应包含:
|
||||
|
||||
- Rspack 开发、快速构建和生产构建配置。
|
||||
- Firefox 52 统一 polyfill 入口和流式传输适配器。
|
||||
- 第三方依赖兼容清单及固定补丁。
|
||||
- CSS 兼容流水线、扫描脚本和视觉基准图。
|
||||
- 按职责拆分的 Firefox 52 手写兼容 CSS,以及对应的体积和重复规则检查。
|
||||
- 路由级功能验收矩阵。
|
||||
- 自动冒烟、危险语法扫描和构建性能脚本。
|
||||
- 基线报告、最终性能对比报告和已知差异说明。
|
||||
- 开发运行、离线构建、部署和故障排查文档。
|
||||
|
||||
## 15. 最终验收清单
|
||||
|
||||
- [x] Firefox 52 开发服务器可直接打开并调试。
|
||||
- [x] 登录、导航、主题、路由刷新无白屏。
|
||||
- [x] 普通问答和智能体问答可以稳定流式输出。
|
||||
- [ ] 上传、下载、Markdown、公式、代码、流程图和 Word 导出通过。
|
||||
- [ ] AI 写作、研究、会商、知识库、任务深链等 P1 功能全部通过。
|
||||
- [ ] 管理端、设置、公开页和扩展能力完成 P2 验证。
|
||||
- [ ] 所有现有主题和目标分辨率完成视觉回归。
|
||||
- [x] 产物通过危险语法和资源路径扫描。
|
||||
- [ ] 冷构建、热构建和增量构建达到性能门槛(冷/热生产构建已记录,增量构建仍需单独量化)。
|
||||
- [ ] 连续构建与长时间运行无 chunk 丢失和内存异常。
|
||||
- [ ] 新增功能与依赖具备持续兼容检查。
|
||||
- [ ] 离线环境可以重复安装、构建和部署。
|
||||
@@ -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 改回并行
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# 技能知识归纳与“知识梳理”助手知识库
|
||||
|
||||
助手知识库的目录、正文、来源和任务记录读取已提交快照,不等待后台导入的全局写锁,也不重复统计整库内容。导入仍在提交前完成实体及 Wiki 对齐,旧数据在检索、导出时保留兼容对齐。页面和展示权限的读取支持取消及 30 秒超时,失败显示可重试错误,避免一直加载;大目录分页失败后停止自动续页并保留已加载正文。来源列表仅在打开「来源与同步」或「导入 / 导出记录」时请求。
|
||||
|
||||
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
|
||||
|
||||
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
|
||||
|
||||
@@ -93,7 +93,8 @@ frontend-web/
|
||||
|
||||
| 脚本 | 命令 | 说明 |
|
||||
|---|---|---|
|
||||
| `pnpm dev` | `vite` | 启动开发服务器(默认端口 5173;项目实际通过仓库根脚本统一为 5174) |
|
||||
| `pnpm dev` / `npm run dev` | `rspack serve --config rspack.firefox52.config.mjs --mode development` | 兼容分支默认启动 Firefox 52 开发服务器,端口 5174 |
|
||||
| `pnpm dev:vite` / `npm run dev:vite` | `vite` | 仅在现代浏览器调试时启动原 Vite 开发服务器 |
|
||||
| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) |
|
||||
| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 |
|
||||
| `pnpm typecheck` | `tsc --noEmit` | 仅做 TS 类型检查,不输出文件 |
|
||||
|
||||
+2
-44
@@ -47,52 +47,10 @@
|
||||
} catch (err) {}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// React 19's development profiler uses the User Timing Level 3
|
||||
// `performance.measure(name, options)` overload. Firefox 101 only
|
||||
// implements the legacy string-marker form and otherwise throws a
|
||||
// DOMException during every scheduled update. Detect that overload
|
||||
// before React loads and disable its optional profiler when unavailable.
|
||||
(function () {
|
||||
try {
|
||||
var perf = window.performance;
|
||||
if (!perf || typeof perf.measure !== "function" || typeof perf.now !== "function") return;
|
||||
var probe = "__deerflow_user_timing_options_probe__";
|
||||
var now = perf.now();
|
||||
perf.measure(probe, { start: now, end: now });
|
||||
if (typeof perf.clearMeasures === "function") perf.clearMeasures(probe);
|
||||
} catch (e) {
|
||||
try {
|
||||
Object.defineProperty(window.performance, "measure", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
} catch (defineError) {
|
||||
try {
|
||||
window.performance.measure = undefined;
|
||||
} catch (assignmentError) {}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script>
|
||||
// Relative production assets need a directory URL. Normalize a mounted
|
||||
// prefix such as /web to /web/ before the browser resolves asset URLs.
|
||||
(function () {
|
||||
var path = window.location.pathname;
|
||||
if (path !== "/" && !path.endsWith("/") && !/\.[^/]+$/.test(path)) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
path + "/" + window.location.search + window.location.hash,
|
||||
);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
<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>
|
||||
|
||||
Generated
+5061
-553
File diff suppressed because it is too large.
Load diff
+51
-14
@@ -4,26 +4,40 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "rspack serve --config rspack.firefox52.config.mjs --mode development",
|
||||
"dev:vite": "vite",
|
||||
"dev:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode development",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
||||
"build:firefox52": "rspack build --config rspack.firefox52.config.mjs --mode production",
|
||||
"build:firefox52:fast": "rspack build --config rspack.firefox52.config.mjs --mode production --env fast=true",
|
||||
"build:firefox52:vite": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode firefox52 --minify false",
|
||||
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
|
||||
"build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test",
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"preview:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode production --env preview=true",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:thread-history": "node scripts/test-thread-history.mjs",
|
||||
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
||||
"test:answer-feedback": "node --experimental-strip-types --test src/components/workspace/agents/agent-feedback-visibility.test.ts src/components/workspace/messages/answer-feedback-eligibility.test.ts src/strategy-components/api/answer-feedback-batching.test.ts",
|
||||
"test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts",
|
||||
"test:abort-signal": "node --experimental-strip-types --test src/core/compat/abort-signal.test.ts",
|
||||
"test:business-chain": "npx esbuild src/roundtable-planning/lib/business-chain.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/business-chain.test.mjs && node --test .runtime/unit-tests/business-chain.test.mjs",
|
||||
"test:auth-entry": "node scripts/test-auth-entry.mjs",
|
||||
"test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs",
|
||||
"test:firefox52-fetch": "node --experimental-strip-types --test src/core/compat/firefox52-fetch.test.ts",
|
||||
"test:firefox52-markdown": "node --experimental-strip-types --test src/core/rehype/index.test.ts",
|
||||
"smoke:firefox52": "node scripts/smoke-firefox52.mjs",
|
||||
"smoke:firefox52:live": "node scripts/smoke-firefox52.mjs --live",
|
||||
"smoke:firefox52:agent-live": "node scripts/smoke-firefox52-agent-live.mjs",
|
||||
"smoke:firefox52:full": "node scripts/smoke-firefox52-full.mjs",
|
||||
"smoke:firefox52:themes": "node scripts/smoke-firefox52-themes.mjs",
|
||||
"benchmark:firefox52": "node scripts/benchmark-firefox52.mjs",
|
||||
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
|
||||
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:firefox52-css": "node scripts/check-firefox52-css.mjs",
|
||||
"check:firefox52-js": "node scripts/check-firefox52-js.mjs",
|
||||
"test:firefox52-build": "node --test build/firefox52/legacy-generator-loader.test.cjs build/firefox52/source-compat-loader.test.cjs",
|
||||
"check:firefox52": "pnpm test:firefox52-build && pnpm test:firefox52-markdown && pnpm check:firefox52-css && pnpm check:firefox52-js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -77,31 +91,35 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-js": "3.50.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fflate": "^0.8.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"maplibre-gl": "4.7.1",
|
||||
"marked": "16.4.2",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"pepjs": "0.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -119,23 +137,42 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@rspack/cli": "2.2.8",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@swc/core": "1.16.13",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-legacy": "6.1.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"acorn": "8.19.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-loader": "10.0.0",
|
||||
"intersection-observer": "^0.12.2",
|
||||
"lightningcss": "^1.30.2",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"postcss-loader": "8.2.1",
|
||||
"postcss-selector-parser": "^6.1.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"terser": "^5.51.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
"vite-plugin-source-identifier": "^1.1.2",
|
||||
"web-streams-polyfill": "4.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
"packageManager": "pnpm@10.26.2",
|
||||
"browserslist": [
|
||||
"Firefox >= 52"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import legacyColorFallback from "./scripts/postcss-legacy-color-fallback.mjs";
|
||||
import lightningCssLegacyChrome from "./scripts/postcss-lightningcss-chrome109.mjs";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import darkDescendant from "./scripts/postcss-dark-descendant.mjs";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
legacyColorFallback(),
|
||||
lightningCssLegacyChrome(),
|
||||
],
|
||||
plugins: [tailwindcss, darkDescendant, autoprefixer],
|
||||
};
|
||||
@@ -155,11 +155,6 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
// 对话历史页「收藏」右侧的「清空」按钮。
|
||||
// "false" 隐藏;不填、删掉这一项,或填其它值,都显示。
|
||||
// 确认后会删除当前账号的全部对话,以及该账号下所有智能体的聊天记录。
|
||||
VITE_CHAT_HISTORY_CLEAR_VISIBLE: "true",
|
||||
|
||||
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
CopyRspackPlugin,
|
||||
DefinePlugin,
|
||||
HtmlRspackPlugin,
|
||||
LightningCssMinimizerRspackPlugin,
|
||||
SwcJsMinimizerRspackPlugin,
|
||||
} from "@rspack/core";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const firefox52Target = { firefox: "52" };
|
||||
|
||||
function loadViteEnvironment(mode) {
|
||||
const values = {};
|
||||
const files = [
|
||||
".env",
|
||||
`.env.${mode}`,
|
||||
".env.local",
|
||||
`.env.${mode}.local`,
|
||||
];
|
||||
|
||||
for (const filename of files) {
|
||||
const fullPath = path.join(rootDir, filename);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
Object.assign(values, dotenv.parse(fs.readFileSync(fullPath)));
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("VITE_") && typeof value === "string") {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(values).filter(([key]) => key.startsWith("VITE_")),
|
||||
),
|
||||
BASE_URL: process.env.VITE_ASSET_BASE ?? "./",
|
||||
MODE: mode,
|
||||
DEV: mode === "development",
|
||||
PROD: mode === "production",
|
||||
SSR: false,
|
||||
};
|
||||
}
|
||||
|
||||
function cssLoaders(postcssConfig) {
|
||||
const loaders = [
|
||||
{
|
||||
loader: "builtin:lightningcss-loader",
|
||||
options: {
|
||||
targets: firefox52Target,
|
||||
include: {
|
||||
logicalProperties: true,
|
||||
},
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (postcssConfig) {
|
||||
loaders.push({
|
||||
loader: "postcss-loader",
|
||||
options: {
|
||||
implementation: "postcss",
|
||||
postcssOptions: {
|
||||
config: path.join(rootDir, "build", "firefox52", postcssConfig),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return loaders;
|
||||
}
|
||||
|
||||
function swcLoader(isDevelopment) {
|
||||
return {
|
||||
loader: "builtin:swc-loader",
|
||||
options: {
|
||||
detectSyntax: "auto",
|
||||
isModule: "unknown",
|
||||
env: {
|
||||
targets: firefox52Target,
|
||||
bugfixes: true,
|
||||
shippedProposals: true,
|
||||
},
|
||||
jsc: {
|
||||
externalHelpers: false,
|
||||
transform: {
|
||||
react: {
|
||||
runtime: "automatic",
|
||||
development: isDevelopment,
|
||||
refresh: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default (_env = {}, argv = {}) => {
|
||||
const mode = argv.mode === "development" ? "development" : "production";
|
||||
const isDevelopment = mode === "development";
|
||||
const isFastBuild = _env.fast === true || _env.fast === "true";
|
||||
const isPreview = _env.preview === true || _env.preview === "true";
|
||||
const assetBase = isDevelopment || isPreview
|
||||
? "/"
|
||||
: process.env.VITE_ASSET_BASE ?? "./";
|
||||
const buildEnv = loadViteEnvironment(mode);
|
||||
|
||||
return {
|
||||
name: "firefox52",
|
||||
mode,
|
||||
context: rootDir,
|
||||
target: ["web", "es5"],
|
||||
entry: {
|
||||
app: path.join(rootDir, "src", "main.tsx"),
|
||||
},
|
||||
output: {
|
||||
path: path.join(rootDir, "dist-firefox52"),
|
||||
publicPath: assetBase,
|
||||
filename: isDevelopment ? "assets/[name].js" : "assets/[name].[contenthash:8].js",
|
||||
chunkFilename: isDevelopment
|
||||
? "assets/[name].chunk.js"
|
||||
: "assets/[name].[contenthash:8].chunk.js",
|
||||
cssFilename: isDevelopment
|
||||
? "assets/[name].css"
|
||||
: "assets/[name].[contenthash:8].css",
|
||||
cssChunkFilename: isDevelopment
|
||||
? "assets/[name].chunk.css"
|
||||
: "assets/[name].[contenthash:8].chunk.css",
|
||||
clean: true,
|
||||
module: false,
|
||||
environment: {
|
||||
arrowFunction: false,
|
||||
asyncFunction: false,
|
||||
bigIntLiteral: false,
|
||||
const: false,
|
||||
destructuring: false,
|
||||
dynamicImport: false,
|
||||
module: false,
|
||||
optionalChaining: false,
|
||||
templateLiteral: false,
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
type: "persistent",
|
||||
name: `firefox52-${mode}`,
|
||||
version: "2-tailwind3",
|
||||
buildDependencies: [
|
||||
fileURLToPath(import.meta.url),
|
||||
path.join(rootDir, "package.json"),
|
||||
path.join(rootDir, "postcss.config.js"),
|
||||
path.join(rootDir, "tailwind.config.ts"),
|
||||
path.join(rootDir, "scripts", "postcss-dark-descendant.mjs"),
|
||||
path.join(rootDir, "scripts", "postcss-firefox52-rgb-vars.mjs"),
|
||||
path.join(rootDir, "scripts", "postcss-firefox52-where.mjs"),
|
||||
path.join(rootDir, "build", "firefox52", "source-compat-loader.cjs"),
|
||||
path.join(rootDir, "build", "firefox52", "legacy-generator-loader.cjs"),
|
||||
path.join(rootDir, "build", "firefox52", "postcss-tailwind.config.mjs"),
|
||||
path.join(rootDir, "build", "firefox52", "postcss-compat.config.mjs"),
|
||||
],
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.join(rootDir, ".rspack-cache"),
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
css: true,
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
devtool: isDevelopment ? "cheap-module-source-map" : false,
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
enforce: "pre",
|
||||
test(resourcePath) {
|
||||
const id = resourcePath.replaceAll("\\", "/");
|
||||
return (
|
||||
/\/p-queue\/dist\/index\.js$/.test(id) ||
|
||||
/\/mdast-util-gfm-autolink-literal\/lib\/index\.js$/.test(id) ||
|
||||
id.includes("/oniguruma-parser/") ||
|
||||
id.includes("/node_modules/mermaid/dist/chunks/") ||
|
||||
id.includes("/node_modules/web-streams-polyfill/dist/") ||
|
||||
id.includes("/node_modules/@xyflow/react/") ||
|
||||
id.includes("/node_modules/react-router/") ||
|
||||
id.includes("/node_modules/zod/") ||
|
||||
id.includes("/node_modules/langsmith/")
|
||||
);
|
||||
},
|
||||
use: path.join(rootDir, "build", "firefox52", "source-compat-loader.cjs"),
|
||||
},
|
||||
{
|
||||
oneOf: [
|
||||
{
|
||||
resourceQuery: /raw/,
|
||||
type: "asset/source",
|
||||
},
|
||||
{
|
||||
test(resourcePath) {
|
||||
const id = resourcePath.replaceAll("\\", "/");
|
||||
return (
|
||||
id.includes("/node_modules/@langchain/core/") ||
|
||||
id.includes("/node_modules/@langchain/langgraph-sdk/") ||
|
||||
id.includes("/node_modules/langsmith/")
|
||||
);
|
||||
},
|
||||
// Loaders run right-to-left: normalize modules that declare or
|
||||
// consume async iterators first, then let the fast built-in SWC
|
||||
// pass handle the complete package consistently.
|
||||
use: [
|
||||
swcLoader(isDevelopment),
|
||||
path.join(rootDir, "build", "firefox52", "legacy-generator-loader.cjs"),
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(?:js|mjs|cjs|jsx|ts|tsx)$/,
|
||||
exclude: [
|
||||
/node_modules[\\/]core-js[\\/]/,
|
||||
/node_modules[\\/]regenerator-runtime[\\/]/,
|
||||
/node_modules[\\/]web-streams-polyfill[\\/]/,
|
||||
],
|
||||
use: swcLoader(isDevelopment),
|
||||
},
|
||||
{
|
||||
test: /[\\/]src[\\/]styles[\\/]globals\.css$/,
|
||||
type: "css",
|
||||
use: cssLoaders("postcss-tailwind.config.mjs"),
|
||||
},
|
||||
{
|
||||
test: /[\\/]src[\\/].*\.css$/,
|
||||
type: "css",
|
||||
use: cssLoaders("postcss-compat.config.mjs"),
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
type: "css",
|
||||
use: cssLoaders(null),
|
||||
},
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: "webassembly/async",
|
||||
},
|
||||
{
|
||||
test: /\.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot)$/i,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name].[contenthash:8][ext]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".json"],
|
||||
alias: {
|
||||
"highlight.js/lib/languages/sql_more$": "highlight.js/lib/languages/sql",
|
||||
"@/env$": path.join(rootDir, "src", "env.ts"),
|
||||
"next/navigation$": path.join(rootDir, "src", "shims", "next-navigation.ts"),
|
||||
"next/link$": path.join(rootDir, "src", "shims", "next-link.tsx"),
|
||||
"@vite": path.join(rootDir, "src"),
|
||||
"@": path.join(rootDir, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
new DefinePlugin({
|
||||
"import.meta.env": JSON.stringify(buildEnv),
|
||||
__FIREFOX52_BUILD__: "true",
|
||||
}),
|
||||
new HtmlRspackPlugin({
|
||||
template: path.join(rootDir, "build", "firefox52", "index.html"),
|
||||
filename: "index.html",
|
||||
inject: "body",
|
||||
scriptLoading: "blocking",
|
||||
minify: false,
|
||||
}),
|
||||
new CopyRspackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.join(rootDir, "public"),
|
||||
to: ".",
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
minimize: !isDevelopment && !isFastBuild,
|
||||
minimizer: [
|
||||
new SwcJsMinimizerRspackPlugin({
|
||||
minimizerOptions: {
|
||||
ecma: 5,
|
||||
},
|
||||
}),
|
||||
new LightningCssMinimizerRspackPlugin({
|
||||
minimizerOptions: {
|
||||
targets: firefox52Target,
|
||||
include: {
|
||||
logicalProperties: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
devServer: {
|
||||
host: "0.0.0.0",
|
||||
port: Number(process.env.FIREFOX52_PORT || 5174),
|
||||
hot: false,
|
||||
liveReload: true,
|
||||
historyApiFallback: true,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
static: {
|
||||
directory: isPreview
|
||||
? path.join(rootDir, "dist-firefox52")
|
||||
: path.join(rootDir, "public"),
|
||||
watch: !isPreview,
|
||||
},
|
||||
devMiddleware: {
|
||||
publicPath: "/",
|
||||
writeToDisk: isPreview,
|
||||
},
|
||||
client: {
|
||||
overlay: true,
|
||||
},
|
||||
},
|
||||
stats: "errors-warnings",
|
||||
performance: false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const rounds = Math.max(1, Number(process.env.FIREFOX_BENCHMARK_ROUNDS || 5));
|
||||
const cold = process.argv.includes("--cold");
|
||||
const fast = process.argv.includes("--fast");
|
||||
const command = fast ? "build:firefox52:fast" : "build:firefox52";
|
||||
const reportDir = path.join(rootDir, ".runtime", "firefox52-benchmark");
|
||||
|
||||
function removeWorkspaceDirectory(relativePath) {
|
||||
const target = path.resolve(rootDir, relativePath);
|
||||
if (path.dirname(target) !== rootDir) throw new Error(`拒绝清理工作区之外的目录:${target}`);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runBuild() {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = performance.now();
|
||||
const executable = process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : "pnpm";
|
||||
const args = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${command}`] : [command];
|
||||
const child = spawn(executable, args, {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
resolve({
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
exitCode: code,
|
||||
signal,
|
||||
});
|
||||
});
|
||||
child.on("error", (error) => resolve({ durationMs: Math.round(performance.now() - startedAt), exitCode: -1, error: String(error) }));
|
||||
});
|
||||
}
|
||||
|
||||
function percentile(values, ratio) {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.ceil(ratio * sorted.length) - 1];
|
||||
}
|
||||
|
||||
function artifactStats() {
|
||||
const assetsDir = path.join(rootDir, "dist-firefox52", "assets");
|
||||
const stats = { jsFiles: 0, jsBytes: 0, cssFiles: 0, cssBytes: 0 };
|
||||
if (!fs.existsSync(assetsDir)) return stats;
|
||||
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
const size = fs.statSync(path.join(assetsDir, entry.name)).size;
|
||||
if (/\.js$/i.test(entry.name)) {
|
||||
stats.jsFiles += 1;
|
||||
stats.jsBytes += size;
|
||||
} else if (/\.css$/i.test(entry.name)) {
|
||||
stats.cssFiles += 1;
|
||||
stats.cssBytes += size;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
if (cold) {
|
||||
const productionCache = path.resolve(rootDir, ".rspack-cache", "firefox52-production");
|
||||
if (!productionCache.startsWith(`${path.resolve(rootDir, ".rspack-cache")}${path.sep}`)) {
|
||||
throw new Error(`拒绝清理预期缓存目录之外的路径:${productionCache}`);
|
||||
}
|
||||
fs.rmSync(productionCache, { recursive: true, force: true });
|
||||
removeWorkspaceDirectory("dist-firefox52");
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
for (let index = 0; index < rounds; index += 1) {
|
||||
console.log(`\n[Firefox 52 benchmark] ${index + 1}/${rounds} ${command}`);
|
||||
const sample = await runBuild();
|
||||
samples.push(sample);
|
||||
if (sample.exitCode !== 0) break;
|
||||
}
|
||||
|
||||
const successfulDurations = samples.filter((item) => item.exitCode === 0).map((item) => item.durationMs);
|
||||
const steadyDurations = successfulDurations.slice(1);
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
||||
const report = {
|
||||
generatedAt: now.toISOString(),
|
||||
command: `pnpm ${command}`,
|
||||
coldCacheBeforeFirstRun: cold,
|
||||
requestedRounds: rounds,
|
||||
environment: {
|
||||
platform: `${os.platform()} ${os.release()} ${os.arch()}`,
|
||||
cpu: os.cpus()[0]?.model || "unknown",
|
||||
logicalCpus: os.cpus().length,
|
||||
totalMemoryBytes: os.totalmem(),
|
||||
node: process.version,
|
||||
pnpm: process.env.npm_config_user_agent || "run via pnpm.cmd",
|
||||
},
|
||||
samples,
|
||||
summary: {
|
||||
successfulRuns: successfulDurations.length,
|
||||
medianMs: percentile(successfulDurations, 0.5),
|
||||
p95Ms: percentile(successfulDurations, 0.95),
|
||||
minMs: successfulDurations.length ? Math.min(...successfulDurations) : null,
|
||||
maxMs: successfulDurations.length ? Math.max(...successfulDurations) : null,
|
||||
steadyStateMedianMs: percentile(steadyDurations, 0.5),
|
||||
steadyStateP95Ms: percentile(steadyDurations, 0.95),
|
||||
},
|
||||
artifacts: artifactStats(),
|
||||
};
|
||||
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const jsonPath = path.join(reportDir, `${stamp}.json`);
|
||||
const markdownPath = path.join(reportDir, `${stamp}.md`);
|
||||
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
fs.writeFileSync(markdownPath, `# Firefox 52 Rspack 构建基准\n\n- 时间:${report.generatedAt}\n- 命令:\`${report.command}\`\n- 轮次:${samples.length}\n- 全部中位数:${report.summary.medianMs ?? "-"} ms\n- 全部 P95:${report.summary.p95Ms ?? "-"} ms\n- 首轮后的稳态中位数:${report.summary.steadyStateMedianMs ?? "-"} ms\n- 首轮后的稳态 P95:${report.summary.steadyStateP95Ms ?? "-"} ms\n- JS:${report.artifacts.jsFiles} 个 / ${report.artifacts.jsBytes} bytes\n- CSS:${report.artifacts.cssFiles} 个 / ${report.artifacts.cssBytes} bytes\n\n| 轮次 | 耗时(ms) | 退出码 |\n|---:|---:|---:|\n${samples.map((sample, index) => `| ${index + 1} | ${sample.durationMs} | ${sample.exitCode} |`).join("\n")}\n`);
|
||||
|
||||
console.log(`\nFirefox 52 benchmark median=${report.summary.medianMs}ms p95=${report.summary.p95Ms}ms steadyMedian=${report.summary.steadyStateMedianMs}ms`);
|
||||
console.log(`JSON: ${jsonPath}`);
|
||||
console.log(`Markdown: ${markdownPath}`);
|
||||
if (successfulDurations.length !== rounds) process.exitCode = 1;
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postcss from "postcss";
|
||||
|
||||
/**
|
||||
* Firefox 52 CSS 产物守卫:构建后运行,防止回归。
|
||||
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
|
||||
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
|
||||
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式;
|
||||
* - :where() 由 Firefox 专用 PostCSS 插件展开;
|
||||
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
|
||||
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
|
||||
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
|
||||
* :has() 按渐进增强放行,关键输入框布局由显式 class 和 :focus-within 兜底。
|
||||
*/
|
||||
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
|
||||
const assetsDir = path.resolve("dist-firefox52/assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error("dist-firefox52/assets 不存在,请先运行 pnpm build:firefox52:fast");
|
||||
}
|
||||
|
||||
const cssFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".css"));
|
||||
if (cssFiles.length === 0) throw new Error("dist-firefox52/assets 中没有 CSS 构建产物");
|
||||
|
||||
const failures = [];
|
||||
const hardTokens = [
|
||||
[/@layer\b/, "@layer(Chrome 99+)"],
|
||||
[/@property\b/, "@property(Chrome 85+)"],
|
||||
[/:is\(/, ":is()(Chrome 88+)"],
|
||||
[/:where\(/, ":where()(Firefox 78+)"],
|
||||
[/\(width\s*>=|\(width\s*<=|\(height\s*>=/, "媒体查询 range 语法(Chrome 104+)"],
|
||||
[/rgb\(\s*var\(/i, "rgb(var(...)) 现代通道语法"],
|
||||
[/rgba\([^;{}]*,\s*calc\(/i, "rgba() 的 calc alpha(Firefox 52 不解析)"],
|
||||
[/rgba\([^;{}]*,\s*[\d.]+%\s*\)/i, "rgba() 百分比 alpha(Firefox 52 不解析)"],
|
||||
];
|
||||
let guardedColors = 0;
|
||||
let progressiveHas = 0;
|
||||
const requiredComposerWidths = new Map([
|
||||
[".max-w-\\[var\\(--container-width-md\\)\\]", "816px"],
|
||||
[".max-w-\\[calc\\(var\\(--container-width-md\\)\\+150px\\)\\]", "966px"],
|
||||
[".max-w-\\[var\\(--container-width-lg\\)\\]", "1024px"],
|
||||
]);
|
||||
const foundComposerWidths = new Map();
|
||||
|
||||
for (const file of cssFiles) {
|
||||
const raw = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
||||
for (const [re, label] of hardTokens) {
|
||||
const hits = raw.match(new RegExp(re.source, "g")) || [];
|
||||
if (hits.length > 0) failures.push(`${file}: ${hits.length} 处 ${label}`);
|
||||
}
|
||||
progressiveHas += (raw.match(/:has\(/g) || []).length;
|
||||
|
||||
const root = postcss.parse(raw, { from: file });
|
||||
root.walkRules((rule) => {
|
||||
for (const [selector, expectedWidth] of requiredComposerWidths) {
|
||||
if (!rule.selector?.includes(selector)) continue;
|
||||
const width = rule.nodes?.find((node) => node.type === "decl" && node.prop === "max-width");
|
||||
if (width?.value === expectedWidth) foundComposerWidths.set(selector, true);
|
||||
}
|
||||
});
|
||||
root.walkDecls((decl) => {
|
||||
if (!unsupportedColor.test(decl.value)) return;
|
||||
guardedColors += 1;
|
||||
let anc = decl.parent;
|
||||
while (anc) {
|
||||
if (anc.type === "atrule" && anc.name === "supports" && unsupportedColor.test(anc.params)) return;
|
||||
anc = anc.parent;
|
||||
}
|
||||
let prev = decl.prev();
|
||||
while (prev?.type === "comment") prev = prev.prev();
|
||||
const hasFallback =
|
||||
!decl.prop.startsWith("--") &&
|
||||
prev?.type === "decl" &&
|
||||
prev.prop === decl.prop &&
|
||||
!unsupportedColor.test(prev.value);
|
||||
if (!hasFallback) {
|
||||
failures.push(`${file}: 未守护的现代颜色 ${decl.prop}: ${decl.value.slice(0, 60)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const selector of requiredComposerWidths.keys()) {
|
||||
if (!foundComposerWidths.has(selector)) {
|
||||
failures.push(`缺少 Firefox 52 输入区宽度回退:${selector}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Firefox 52 CSS 兼容检查失败:\n");
|
||||
console.error(failures.slice(0, 50).join("\n"));
|
||||
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Firefox 52 CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明,${progressiveHas} 处渐进式 :has())。`,
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { parse } from "acorn";
|
||||
|
||||
const assetsDir = path.resolve("dist-firefox52/assets");
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error("dist-firefox52/assets 不存在,请先运行 pnpm build:firefox52");
|
||||
}
|
||||
|
||||
const jsFiles = fs.readdirSync(assetsDir).filter((file) => file.endsWith(".js"));
|
||||
if (jsFiles.length === 0) {
|
||||
throw new Error("dist-firefox52/assets 中没有 JavaScript 构建产物");
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
let totalBytes = 0;
|
||||
for (const file of jsFiles) {
|
||||
const source = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
||||
totalBytes += Buffer.byteLength(source);
|
||||
try {
|
||||
// Firefox 52 implements ES2017 parsing, but not later syntax such as
|
||||
// optional chaining, BigInt literals, object rest, or dynamic import.
|
||||
parse(source, {
|
||||
ecmaVersion: 2017,
|
||||
sourceType: "script",
|
||||
allowHashBang: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const location = error.loc ? `${error.loc.line}:${error.loc.column}` : "unknown";
|
||||
failures.push(`${file}:${location} ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Firefox 52 JavaScript 语法检查失败:\n");
|
||||
console.error(failures.slice(0, 50).join("\n"));
|
||||
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Firefox 52 JavaScript 语法检查通过(${jsFiles.length} 个文件,${(
|
||||
totalBytes /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(1)} MiB)。`,
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
|
||||
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
|
||||
*
|
||||
* - 特异性不变:`:is(.dark *)` 取最高参数 = (0,1,0),剥离后前置 `.dark `
|
||||
* 同样 +(0,1,0),两种形式均为 (0,2,0),级联行为一致。
|
||||
* - `.dark` 挂在 <html> 上、是全局祖先,前置到组合器最左侧语义等价
|
||||
* (group-hover:dark:* 等组合下同样成立)。
|
||||
* - 堆叠变体(dark:marker:* 等)生成的 `X *:is(.dark *)::marker` 会被
|
||||
* 改写为 `.dark X *::marker`,保持合法。
|
||||
*/
|
||||
const TOKEN = ":is(.dark *)";
|
||||
|
||||
export default function darkDescendant() {
|
||||
return {
|
||||
postcssPlugin: "postcss-dark-descendant",
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(TOKEN)) return;
|
||||
rule.selector = rule.selector
|
||||
.split(",")
|
||||
.map((sel) => {
|
||||
const trimmed = sel.trim();
|
||||
if (!trimmed.includes(TOKEN)) return trimmed;
|
||||
return ".dark " + trimmed.split(TOKEN).join("");
|
||||
})
|
||||
.join(", ");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
darkDescendant.postcss = true;
|
||||
@@ -0,0 +1,164 @@
|
||||
const LEGACY_RGB_NAME = /^--legacy-[a-z0-9-]+-rgb$/i;
|
||||
const RGB_NAME = /^--[a-z0-9-]+-rgb$/i;
|
||||
const RGB_CHANNELS = /^\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*$/;
|
||||
const RGB_CHANNEL_REFERENCE = /^\s*var\(\s*(--[a-z0-9-]+-rgb)(?:\s*,\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?))?\s*\)\s*$/i;
|
||||
const INHERENT_ALPHA_TOKENS = new Set([
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
"sidebar-border",
|
||||
]);
|
||||
const ALPHA_MULTIPLIERS = [0, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 1];
|
||||
|
||||
function alphaVariableSuffix(multiplier) {
|
||||
return String(Math.round(multiplier * 1000));
|
||||
}
|
||||
|
||||
function formatAlpha(value) {
|
||||
return String(Math.round(value * 1_000_000) / 1_000_000);
|
||||
}
|
||||
|
||||
function legacyAlphaToken(variableName) {
|
||||
const match = variableName.match(/^--legacy-(.+)-rgb$/);
|
||||
return match && INHERENT_ALPHA_TOKENS.has(match[1]) ? match[1] : null;
|
||||
}
|
||||
|
||||
function normalizeAlpha(variableName, alpha) {
|
||||
const percentage = alpha.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (percentage) return String(Number(percentage[1]) / 100);
|
||||
|
||||
const token = legacyAlphaToken(variableName);
|
||||
if (!token) return alpha;
|
||||
const composite = alpha.match(
|
||||
new RegExp(
|
||||
`^calc\\(var\\(--${token}-alpha,\\s*1\\)\\s*\\*\\s*(.+)\\)$`,
|
||||
),
|
||||
);
|
||||
if (!composite) return alpha;
|
||||
|
||||
const multiplier = composite[1].trim();
|
||||
const numeric = Number(multiplier);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return `var(--ff52-${token}-alpha-${alphaVariableSuffix(numeric)})`;
|
||||
}
|
||||
if (/^var\(--tw-[a-z-]+-opacity,\s*1\)$/.test(multiplier)) {
|
||||
return `var(--ff52-${token}-alpha-${alphaVariableSuffix(1)})`;
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function findClosingParenthesis(value, openingIndex) {
|
||||
let depth = 0;
|
||||
for (let index = openingIndex; index < value.length; index += 1) {
|
||||
if (value[index] === "(") depth += 1;
|
||||
if (value[index] !== ")") continue;
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function commaChannelValue(value) {
|
||||
const channels = value.match(RGB_CHANNELS);
|
||||
if (channels) return `${channels[1]}, ${channels[2]}, ${channels[3]}`;
|
||||
|
||||
const reference = value.match(RGB_CHANNEL_REFERENCE);
|
||||
if (!reference) return null;
|
||||
const fallback = reference[2]
|
||||
? `, ${reference[2]}, ${reference[3]}, ${reference[4]}`
|
||||
: "";
|
||||
return `var(${reference[1]}-comma${fallback})`;
|
||||
}
|
||||
|
||||
function rewriteLegacyRgbFunctions(
|
||||
value,
|
||||
convertibleNames = { has: (name) => LEGACY_RGB_NAME.test(name) },
|
||||
) {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf("rgb(var(--", cursor);
|
||||
if (start < 0) return output + value.slice(cursor);
|
||||
output += value.slice(cursor, start);
|
||||
|
||||
const varStart = start + 4;
|
||||
const varEnd = findClosingParenthesis(value, varStart + 3);
|
||||
const rgbEnd = findClosingParenthesis(value, start + 3);
|
||||
if (varEnd < 0 || rgbEnd < 0 || varEnd >= rgbEnd) {
|
||||
output += value.slice(start, start + 4);
|
||||
cursor = start + 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
const variableName = value.slice(varStart + 4, varEnd).trim();
|
||||
const separator = value.slice(varEnd + 1, rgbEnd);
|
||||
const slash = separator.indexOf("/");
|
||||
if (!RGB_NAME.test(variableName) || !convertibleNames.has(variableName) || slash < 0) {
|
||||
output += value.slice(start, rgbEnd + 1);
|
||||
cursor = rgbEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const alpha = normalizeAlpha(
|
||||
variableName,
|
||||
separator.slice(slash + 1).trim(),
|
||||
);
|
||||
output += `rgba(var(${variableName}-comma), ${alpha})`;
|
||||
cursor = rgbEnd + 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Firefox 52 cannot parse CSS Color 4's `rgb(var(--channels) / alpha)` form.
|
||||
* Keep the space-separated live channels for modern builds, add an equivalent
|
||||
* comma-separated variable, and rewrite only the Firefox artifact to rgba().
|
||||
*/
|
||||
export default function firefox52RgbVars() {
|
||||
const convertibleNames = new Set();
|
||||
return {
|
||||
postcssPlugin: "postcss-firefox52-rgb-vars",
|
||||
Once(root) {
|
||||
root.walkDecls((declaration) => {
|
||||
if (RGB_NAME.test(declaration.prop)) {
|
||||
const commaValue = commaChannelValue(declaration.value);
|
||||
if (commaValue) {
|
||||
convertibleNames.add(declaration.prop);
|
||||
declaration.cloneAfter({
|
||||
prop: `${declaration.prop}-comma`,
|
||||
value: commaValue,
|
||||
});
|
||||
|
||||
const token = legacyAlphaToken(declaration.prop);
|
||||
if (token) {
|
||||
const alphaDeclaration = declaration.parent?.nodes?.find(
|
||||
(node) =>
|
||||
node.type === "decl" && node.prop === `--${token}-alpha`,
|
||||
);
|
||||
const inherentAlpha = Number(alphaDeclaration?.value ?? 1);
|
||||
for (const multiplier of ALPHA_MULTIPLIERS) {
|
||||
declaration.cloneAfter({
|
||||
prop: `--ff52-${token}-alpha-${alphaVariableSuffix(multiplier)}`,
|
||||
value: formatAlpha(inherentAlpha * multiplier),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
Declaration(declaration) {
|
||||
if (!declaration.value.includes("rgb(var(--")) return;
|
||||
declaration.value = rewriteLegacyRgbFunctions(
|
||||
declaration.value,
|
||||
convertibleNames,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
firefox52RgbVars.postcss = true;
|
||||
|
||||
export { rewriteLegacyRgbFunctions };
|
||||
@@ -0,0 +1,47 @@
|
||||
import selectorParser from "postcss-selector-parser";
|
||||
|
||||
function firstWhere(selector) {
|
||||
let found;
|
||||
selector.walkPseudos((pseudo) => {
|
||||
if (!found && pseudo.value.toLowerCase() === ":where") found = pseudo;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function expandWhere(selector) {
|
||||
const pseudo = firstWhere(selector);
|
||||
if (!pseudo) return [selector];
|
||||
const alternatives = pseudo.nodes ?? [];
|
||||
if (alternatives.length === 0) {
|
||||
pseudo.remove();
|
||||
return expandWhere(selector);
|
||||
}
|
||||
|
||||
return alternatives.flatMap((alternative) => {
|
||||
const clone = selector.clone();
|
||||
const clonePseudo = firstWhere(clone);
|
||||
if (!clonePseudo) return [clone];
|
||||
clonePseudo.replaceWith(...alternative.nodes.map((node) => node.clone()));
|
||||
return expandWhere(clone);
|
||||
});
|
||||
}
|
||||
|
||||
/** Expand :where() only in the Firefox 52 build, where the selector is invalid. */
|
||||
export default function firefox52Where() {
|
||||
return {
|
||||
postcssPlugin: "postcss-firefox52-where",
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(":where(")) return;
|
||||
rule.selector = selectorParser((root) => {
|
||||
const selectors = [...root.nodes];
|
||||
root.removeAll();
|
||||
for (const selector of selectors) {
|
||||
for (const expanded of expandWhere(selector)) root.append(expanded);
|
||||
}
|
||||
}).processSync(rule.selector);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
firefox52Where.postcss = true;
|
||||
|
||||
@@ -2,10 +2,12 @@ import postcss from "postcss";
|
||||
import { transform } from "lightningcss";
|
||||
|
||||
const LEGACY_CHROME = 100 << 16;
|
||||
const LEGACY_FIREFOX = 52 << 16;
|
||||
|
||||
/**
|
||||
* Keep Vite's PostCSS pipeline (required by Tailwind 4), then compile the
|
||||
* resulting CSS to Chrome 100-compatible syntax for both dev and production.
|
||||
* resulting CSS to the supported legacy browser syntax for both dev and
|
||||
* production. Cascade layers are flattened by the preceding PostCSS plugin.
|
||||
*/
|
||||
export default function lightningCssLegacyChrome() {
|
||||
return {
|
||||
@@ -15,7 +17,7 @@ export default function lightningCssLegacyChrome() {
|
||||
const output = transform({
|
||||
filename,
|
||||
code: Buffer.from(root.toString()),
|
||||
targets: { chrome: LEGACY_CHROME },
|
||||
targets: { chrome: LEGACY_CHROME, firefox: LEGACY_FIREFOX },
|
||||
minify: false,
|
||||
});
|
||||
const compiled = postcss.parse(output.code.toString(), { from: filename });
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
|
||||
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
|
||||
const password = process.env.FIREFOX_TEST_PASSWORD || "";
|
||||
|
||||
async function selectAgentId() {
|
||||
if (process.env.FIREFOX_TEST_AGENT_ID) return process.env.FIREFOX_TEST_AGENT_ID;
|
||||
const loginUrl = new URL(`${backendUrl}/api/v1/auth/login/username`);
|
||||
if (password) loginUrl.searchParams.set("password", password);
|
||||
const loginResponse = await fetch(loginUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!loginResponse.ok) throw new Error(`测试账号登录失败:HTTP ${loginResponse.status}`);
|
||||
const auth = await loginResponse.json();
|
||||
const selectorResponse = await fetch(`${backendUrl}/api/agents?admin=true`, {
|
||||
headers: { Authorization: `${auth.token_type || "Bearer"} ${auth.access_token}` },
|
||||
});
|
||||
if (!selectorResponse.ok) throw new Error(`读取智能体列表失败:HTTP ${selectorResponse.status}`);
|
||||
const payload = await selectorResponse.json();
|
||||
const agents = Array.isArray(payload.agents) ? payload.agents : [];
|
||||
const agent = agents.find((item) => item?.id && item?.model) || agents.find((item) => item?.id);
|
||||
if (!agent) throw new Error("智能体列表中没有可用于流式验收的智能体");
|
||||
return String(agent.id);
|
||||
}
|
||||
|
||||
const agentId = await selectAgentId();
|
||||
const executable = process.execPath;
|
||||
const child = spawn(executable, [path.join(rootDir, "scripts", "smoke-firefox52.mjs"), "--live-only"], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
FIREFOX_LIVE_CHAT_HASH: `#/page/workspace/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
FIREFOX_LIVE_CHAT_NAME: `真实智能体流式问答(${agentId})`,
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => resolve(code ?? 1));
|
||||
});
|
||||
process.exitCode = exitCode;
|
||||
@@ -0,0 +1,377 @@
|
||||
import net from "node:net";
|
||||
|
||||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||||
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
|
||||
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
|
||||
const password = process.env.FIREFOX_TEST_PASSWORD || "";
|
||||
const routeTimeoutMs = Number(process.env.FIREFOX_ROUTE_TIMEOUT_MS || 30_000);
|
||||
const fullReload = process.env.FIREFOX_FULL_RELOAD === "1";
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Static routes that can be verified without creating or mutating business
|
||||
* records. Parameterised workflows need their own test data; this list
|
||||
* intentionally rejects unexpected auth redirects.
|
||||
*/
|
||||
const routes = [
|
||||
// Public and standalone shells.
|
||||
["登录页鉴权恢复", `#/login/${encodeURIComponent(username)}`, { redirect: "#/page/workspace/chats/new" }],
|
||||
["落地页", "#/landing"],
|
||||
["公开排行榜", "#/public/leaderboard"],
|
||||
["会商大屏", "#/roundtable-dashboard"],
|
||||
["Official DeerFlow", "#/official"],
|
||||
|
||||
// Workspace routes.
|
||||
["工作区首页", "#/page/workspace/chats/new"],
|
||||
["对话列表", "#/page/workspace/chats"],
|
||||
["项目入口", "#/page/workspace/projects", { redirect: "#/page/strategy/qa/projects" }],
|
||||
["资料库入口", "#/page/workspace/library"],
|
||||
["微信新对话", "#/page/workspace/wx/chats/new"],
|
||||
["微信智能体", "#/page/workspace/wx/agents"],
|
||||
["微信技能", "#/page/workspace/wx/skills"],
|
||||
["智能体列表", "#/page/workspace/agents"],
|
||||
["新建智能体", "#/page/workspace/agents/new"],
|
||||
["智能体协作网络", "#/page/workspace/agents/orbit"],
|
||||
["智能体广场", "#/page/workspace/agents/plaza"],
|
||||
["技能列表", "#/page/workspace/skills"],
|
||||
["技能进化", "#/page/workspace/skills/evolution"],
|
||||
["AI 写作", "#/page/workspace/ai-writing"],
|
||||
["深度研究", "#/page/workspace/deep-research"],
|
||||
["公文助手", "#/page/workspace/gongwen"],
|
||||
["新建报告结构", "#/page/workspace/deep-research/structures/new"],
|
||||
["企业研究", "#/page/workspace/enterprise-research"],
|
||||
["Notebook 列表", "#/page/workspace/studio/notebooks"],
|
||||
["多智能体规划", "#/page/workspace/roundtable/planning"],
|
||||
["8BF 会商", "#/page/workspace/roundtable/8bf"],
|
||||
["岗位会商", "#/page/workspace/roundtable/position"],
|
||||
["业务链编辑", "#/page/workspace/roundtable/chains"],
|
||||
["业务链列表", "#/page/workspace/business-chains"],
|
||||
["报告协作", "#/page/workspace/report-collaboration"],
|
||||
["派活链路预览", "#/page/workspace/roundtable/chain-preview"],
|
||||
["知识空间", "#/page/workspace/knowledge"],
|
||||
["知识图谱配置回退", "#/page/workspace/knowledge/graph", { redirect: "#/page/workspace/knowledge" }],
|
||||
["助手知识库配置回退", "#/page/workspace/knowledge/assistant", { redirect: "#/page/workspace/knowledge" }],
|
||||
["知识检索调试配置回退", "#/page/workspace/knowledge/retrieval-debug", { redirect: "#/page/workspace/knowledge" }],
|
||||
["Markdown 转 Word", "#/page/workspace/knowledge/markdown-editor"],
|
||||
["定时任务", "#/page/workspace/scheduled/tasks"],
|
||||
|
||||
// Workspace admin/settings surfaces.
|
||||
["用户管理中心", "#/page/workspace/admin"],
|
||||
["管理员定时任务", "#/page/workspace/admin/scheduled-tasks"],
|
||||
["管理员智能体", "#/page/workspace/admin/agents"],
|
||||
["管理员技能", "#/page/workspace/admin/skills"],
|
||||
["技能定时", "#/page/workspace/admin/skill-timing"],
|
||||
["模型交换", "#/page/workspace/admin/model-exchanges"],
|
||||
["用户排行榜管理", "#/page/workspace/admin/user-leaderboard"],
|
||||
["用户排行榜设置", "#/page/workspace/admin/user-leaderboard/settings"],
|
||||
["菜单管理", "#/page/workspace/admin/menu-management"],
|
||||
["固定问题", "#/page/workspace/admin/fixed-questions"],
|
||||
["标签管理", "#/page/workspace/admin/tags"],
|
||||
["插件管理", "#/page/workspace/admin/plugins"],
|
||||
["岗位管理", "#/page/workspace/admin/positions"],
|
||||
["岗位角色", "#/page/workspace/admin/position-roles"],
|
||||
["推荐问题", "#/page/workspace/settings/recommended-questions"],
|
||||
["提示词前缀", "#/page/workspace/settings/prompt-prefix"],
|
||||
["私有广场", "#/page/workspace/settings/private-square"],
|
||||
["模型管理", "#/page/workspace/admin/models"],
|
||||
["模型并发", "#/page/workspace/settings/model-concurrency"],
|
||||
["广场发布", "#/page/workspace/settings/publish-squares"],
|
||||
["技能压缩", "#/page/workspace/settings/skill-compression"],
|
||||
["LLM 指标", "#/page/workspace/settings/llm-metrics"],
|
||||
["Checkpoint 迁移", "#/page/workspace/settings/checkpoint-migration"],
|
||||
|
||||
// Strategy routes and legacy/full-layout entry points.
|
||||
["通用问答", "#/page/strategy/qa/general"],
|
||||
["分析项目", "#/page/strategy/qa/projects"],
|
||||
["方案项目", "#/page/strategy/qa/projects/solutions", { redirect: "#/page/strategy/qa/projects?type=solution" }],
|
||||
["问答项目", "#/page/strategy/qa/projects/groups", { redirect: "#/page/strategy/qa/projects?type=qa" }],
|
||||
["策略选择配置回退", "#/page/strategy/qa/strategy-selection", { redirect: "#/page/strategy/qa/projects" }],
|
||||
["专家问答", "#/page/strategy/qa/expert-qa"],
|
||||
["策略多智能体会商", "#/page/strategy/qa/multi-agent-roundtable"],
|
||||
["策略 8BF 会商", "#/page/strategy/qa/8bf-roundtable"],
|
||||
["策略岗位会商", "#/page/strategy/qa/position-roundtable"],
|
||||
["策略深度研究", "#/page/strategy/qa/deep-research"],
|
||||
["策略公文助手", "#/page/strategy/qa/gongwen"],
|
||||
["策略新建报告结构", "#/page/strategy/qa/deep-research/structures/new"],
|
||||
["策略企业研究", "#/page/strategy/qa/enterprise-research"],
|
||||
["智能体看板", "#/page/strategy/dashboard-agent"],
|
||||
["任务管理", "#/page/strategy/task-management"],
|
||||
["三情分析任务", "#/page/strategy/task-management/plan-task-sentiment"],
|
||||
["任务分析", "#/page/strategy/task-management/plan-task/plan-task-fx"],
|
||||
["行动分析", "#/page/strategy/task-management/plan-task/plan-task-action"],
|
||||
["任务三情会商", "#/page/strategy/task-management/plan-task-sentiment/roundtable"],
|
||||
["任务分析会商", "#/page/strategy/task-management/plan-task/plan-task-fx/roundtable"],
|
||||
["行动分析会商", "#/page/strategy/task-management/plan-task/plan-task-action/roundtable"],
|
||||
["网络行为体资源", "#/page/strategy/resource-management/resource-network-actor"],
|
||||
["知识库资源", "#/page/strategy/resource-management/resource-knowledge-base"],
|
||||
["评价指标资源", "#/page/strategy/resource-management/resource-evaluation-index"],
|
||||
["轻应用管理", "#/page/strategy/light-app/management"],
|
||||
["轻应用列表", "#/page/strategy/light-app/apps"],
|
||||
["轻应用新建笔记", "#/page/strategy/light-app/new-note"],
|
||||
["轻应用笔记", "#/page/strategy/light-app/notes"],
|
||||
["轻应用笔记问答", "#/page/strategy/light-app/note-qa"],
|
||||
["策略对话", "#/page/strategy/chats"],
|
||||
["策略智能体", "#/page/strategy/agents"],
|
||||
["策略智能体广场", "#/page/strategy/agents/plaza"],
|
||||
["策略新建智能体", "#/page/strategy/agents/new"],
|
||||
["策略技能", "#/page/strategy/skills"],
|
||||
["策略技能进化", "#/page/strategy/skills/evolution"],
|
||||
["业务链映射", "#/page/strategy/business-chain-mapping"],
|
||||
["策略业务链列表", "#/page/strategy/business-chains"],
|
||||
["策略业务链编辑", "#/page/strategy/roundtable/chains"],
|
||||
["策略定时任务", "#/page/strategy/scheduled/tasks"],
|
||||
["登录白名单", "#/page/strategy/admin/login-whitelist"],
|
||||
["会商诊断", "#/page/strategy/admin/roundtable-diagnostics"],
|
||||
["技能地址", "#/page/strategy/admin/skill-addresses"],
|
||||
["敏感词权限回退", "#/page/strategy/admin/sensitive-words", { redirect: "#/page/workspace/chats/new" }],
|
||||
["任务按钮", "#/page/strategy/admin/task-buttons"],
|
||||
["策略管理员定时任务", "#/page/strategy/admin/scheduled-tasks"],
|
||||
["策略排行榜", "#/page/strategy/user-leaderboard"],
|
||||
["策略用户活动", "#/page/strategy/admin/user-activity"],
|
||||
["策略标签", "#/page/strategy/admin/tags"],
|
||||
["策略岗位", "#/page/strategy/admin/positions"],
|
||||
|
||||
// Parallel canvas/notebook/roundtable route trees.
|
||||
["画布首页", "#/page/canvas"],
|
||||
["画布 AI 写作", "#/page/canvas/ai-writing"],
|
||||
["对话版 AI 写作", "#/page/canvas/ai-writing-chat"],
|
||||
["文章类型", "#/page/canvas/article-types"],
|
||||
["研究模板", "#/page/canvas/research-templates"],
|
||||
["新建研究模板", "#/page/canvas/research-templates/new"],
|
||||
["Notebook AI 写作", "#/page/notebook/ai-writing"],
|
||||
["独立会商", "#/page/roundtable/planning"],
|
||||
// Keep isolated/embed routes last because their sessionStorage contract is
|
||||
// intentionally sticky across in-app navigation.
|
||||
["iframe 新对话", "#/page/workspace/chats_iframe/new?embed=1&hideJump=1&theme=light", { redirect: "#/page/workspace/chats/new?embed=1&hideJump=1&theme=light&chatMode=full" }],
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const browserErrors = [];
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let rootActor;
|
||||
let consoleActor;
|
||||
let pendingEvaluation;
|
||||
|
||||
function send(packet) {
|
||||
const body = Buffer.from(JSON.stringify(packet));
|
||||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingEvaluation = { expression, resolve, reject };
|
||||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIgnoreConsoleError(message) {
|
||||
return (
|
||||
message.includes("validateDOMNesting") ||
|
||||
message.includes("init intent failed") ||
|
||||
message.includes("ResizeObserver loop") ||
|
||||
message === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
function formatConsoleArgument(argument) {
|
||||
if (argument == null || typeof argument !== "object") return String(argument);
|
||||
const preview = argument.preview;
|
||||
if (preview?.message) {
|
||||
return `${preview.name || argument.class || "Error"}: ${preview.message}${preview.stack ? `\n${preview.stack}` : ""}`;
|
||||
}
|
||||
if (argument.class === "Error" && argument.ownPropertyLength === 0) return "Error";
|
||||
if (typeof argument.description === "string") return argument.description;
|
||||
try {
|
||||
return JSON.stringify(argument);
|
||||
} catch {
|
||||
return String(argument);
|
||||
}
|
||||
}
|
||||
|
||||
function handle(packet) {
|
||||
if (packet.applicationType && !rootActor) {
|
||||
rootActor = packet.from;
|
||||
send({ to: rootActor, type: "listTabs" });
|
||||
return;
|
||||
}
|
||||
if (packet.tabs && !consoleActor) {
|
||||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||||
send({
|
||||
to: consoleActor,
|
||||
type: "startListeners",
|
||||
listeners: ["PageError", "ConsoleAPI"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
packet.type === "pageError" &&
|
||||
packet.pageError &&
|
||||
packet.pageError.category !== "CSS Parser" &&
|
||||
packet.pageError.warning !== true
|
||||
) {
|
||||
const pageError = packet.pageError;
|
||||
const errorText = String(pageError.errorMessage || pageError.message || "").trim();
|
||||
const usefulErrorText = /^Error:\s*$/.test(errorText) ? "" : errorText;
|
||||
// Firefox 52 emits a duplicate empty promise-rejection packet when an XHR
|
||||
// owned by the previous document is cancelled during reload. It has no
|
||||
// stack and no message; meaningful runtime failures carry one or both.
|
||||
if (!usefulErrorText && !pageError.stacktrace) return;
|
||||
browserErrors.push(
|
||||
`PAGE: ${usefulErrorText || JSON.stringify(pageError)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||||
const message = (packet.message.arguments || [])
|
||||
.map(formatConsoleArgument)
|
||||
.join(" ") || "error";
|
||||
if (!shouldIgnoreConsoleError(message)) browserErrors.push(`CONSOLE: ${message}`);
|
||||
return;
|
||||
}
|
||||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||||
const pending = pendingEvaluation;
|
||||
pendingEvaluation = null;
|
||||
if (packet.exception) pending.reject(new Error(String(packet.exception)));
|
||||
else pending.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
function consume() {
|
||||
while (true) {
|
||||
const colon = buffer.indexOf(58);
|
||||
if (colon < 0) return;
|
||||
const length = Number(buffer.subarray(0, colon).toString());
|
||||
if (buffer.length < colon + 1 + length) return;
|
||||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||||
buffer = buffer.subarray(colon + 1 + length);
|
||||
handle(packet);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
consume();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
while (!consoleActor) await sleep(50);
|
||||
|
||||
async function waitFor(expression, timeoutMs = routeTimeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await evaluate(expression)) return true;
|
||||
} catch {
|
||||
// A full reload can invalidate the previous Firefox console actor for a
|
||||
// few milliseconds. Retry against the actor once it resumes.
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function installAdminSession() {
|
||||
const url = new URL(`${backendUrl}/api/v1/auth/login/username`);
|
||||
if (password) url.searchParams.set("password", password);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`测试账号登录失败:HTTP ${response.status}`);
|
||||
}
|
||||
const auth = await response.json();
|
||||
if (auth.system_role !== "admin") {
|
||||
throw new Error(`测试账号 ${username} 不是管理员,无法验证管理路由`);
|
||||
}
|
||||
await evaluate(`(function(){var auth=${JSON.stringify(JSON.stringify(auth))},info=${JSON.stringify(JSON.stringify({ userName: username }))},settings={};try{settings=JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}')}catch(e){}settings.display=settings.display||{};settings.display.layoutMode='full';localStorage.setItem('deerflow.local-settings',JSON.stringify(settings));localStorage.setItem('deerflow.auth',auth);localStorage.setItem('userInfo',info);sessionStorage.setItem('deerflow.auth',auth);sessionStorage.setItem('userInfo',info);return true})()`);
|
||||
}
|
||||
|
||||
async function exerciseVisibleTabs() {
|
||||
const count = Number(await evaluate(`(function(){return [].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}).length})()`));
|
||||
let clicks = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const clicked = await evaluate(`(function(){var items=[].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}),item=items[${index}];if(!item)return false;item.click();return true})()`);
|
||||
if (!clicked) continue;
|
||||
clicks += 1;
|
||||
await sleep(120);
|
||||
const healthy = await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),text=document.body.innerText||'';return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
|
||||
if (!healthy) return { pass: false, clicks, detail: `点击第 ${index + 1} 个标签页后进入错误边界` };
|
||||
}
|
||||
return { pass: true, clicks };
|
||||
}
|
||||
|
||||
async function navigate(hash, options = {}) {
|
||||
const expectedHash = options.redirect || hash;
|
||||
const url = new URL(hash, baseUrl).href;
|
||||
try {
|
||||
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)},reload=${JSON.stringify(fullReload)};if(reload){if(location.href!==url)location.href=url;location.reload()}else if(location.href!==url){location.href=url}return true})()`);
|
||||
} catch {
|
||||
// Firefox destroys the old console evaluation context during a successful
|
||||
// full-page reload and occasionally reports that as an opaque exception.
|
||||
// The route/readiness assertion below is the source of truth.
|
||||
}
|
||||
await sleep(700);
|
||||
const ready = await waitFor(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),hasSurface=document.body.innerText.trim().length>0||!!document.querySelector('iframe,canvas,svg,.cm-editor');return location.hash.indexOf(${JSON.stringify(expectedHash)})===0&&document.readyState==='complete'&&!!root&&rect.width>0&&rect.height>0&&root.innerHTML.length>100&&hasSurface})()`);
|
||||
if (!ready) {
|
||||
const state = await evaluate(`(function(){function role(store){try{var value=store.getItem('deerflow.auth');return value?JSON.parse(value).system_role:'none'}catch(e){return 'invalid'}}return location.hash+';local='+role(localStorage)+';session='+role(sessionStorage)+';embed='+sessionStorage.getItem('embed.session')})()`);
|
||||
return `页面未在 ${Math.round(routeTimeoutMs / 1000)} 秒内完成渲染(${state})`;
|
||||
}
|
||||
const healthy = await evaluate(`(function(){var text=document.body.innerText||'';return !/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
|
||||
return healthy === true ? true : "页面进入错误边界";
|
||||
}
|
||||
|
||||
try {
|
||||
await installAdminSession();
|
||||
const routeFilter = process.env.FIREFOX_ROUTE_FILTER
|
||||
? new RegExp(process.env.FIREFOX_ROUTE_FILTER, "i")
|
||||
: null;
|
||||
const selectedRoutes = routeFilter
|
||||
? routes.filter(([name, hash]) => routeFilter.test(`${name} ${hash}`))
|
||||
: routes;
|
||||
if (selectedRoutes.length === 0) {
|
||||
throw new Error("FIREFOX_ROUTE_FILTER 未匹配到路由");
|
||||
}
|
||||
for (const [name, hash, options = {}] of selectedRoutes) {
|
||||
// Let late callbacks from the previous page settle before assigning
|
||||
// errors to the next full-page navigation.
|
||||
await sleep(350);
|
||||
const errorStart = browserErrors.length;
|
||||
let detail;
|
||||
try {
|
||||
detail = await navigate(hash, options);
|
||||
if (detail === true) {
|
||||
const tabResult = await exerciseVisibleTabs();
|
||||
if (!tabResult.pass) detail = tabResult.detail;
|
||||
else results.tabClicks = (results.tabClicks || 0) + tabResult.clicks;
|
||||
}
|
||||
} catch (error) {
|
||||
detail = String(error);
|
||||
}
|
||||
// Allow asynchronous render errors to arrive before assigning them to the
|
||||
// next route. Network/API failures are displayed by the page and do not
|
||||
// become PageError events; uncaught runtime errors still fail the route.
|
||||
await sleep(150);
|
||||
const errors = browserErrors
|
||||
.slice(errorStart)
|
||||
.filter((message) => !message.startsWith("CONSOLE: Warning:"));
|
||||
const pass = detail === true && errors.length === 0;
|
||||
results.push({ name, hash, pass, detail, errors });
|
||||
console.log(`${pass ? "PASS" : "FAIL"} ${name}${pass ? "" : ` — ${detail}`}`);
|
||||
for (const error of errors) console.log(` ${error}`);
|
||||
}
|
||||
} finally {
|
||||
socket.end();
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
console.log(`\nFirefox 52 full route smoke: ${results.length - failed.length}/${results.length} passed`);
|
||||
console.log(`Non-mutating visible tab interactions: ${results.tabClicks || 0}`);
|
||||
console.log(`Navigation mode: ${fullReload ? "full reload" : "SPA"}`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
@@ -0,0 +1,163 @@
|
||||
import net from "node:net";
|
||||
|
||||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const themes = ["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"];
|
||||
const layouts = ["full", "compact"];
|
||||
const results = [];
|
||||
const browserErrors = [];
|
||||
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let rootActor;
|
||||
let consoleActor;
|
||||
let pendingEvaluation;
|
||||
|
||||
function send(packet) {
|
||||
const body = Buffer.from(JSON.stringify(packet));
|
||||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingEvaluation = { expression, resolve, reject };
|
||||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||||
});
|
||||
}
|
||||
|
||||
function formatArgument(argument) {
|
||||
if (argument == null || typeof argument !== "object") return String(argument);
|
||||
if (argument.preview?.message) return `${argument.preview.name || argument.class || "Error"}: ${argument.preview.message}`;
|
||||
return argument.description || argument.class || JSON.stringify(argument);
|
||||
}
|
||||
|
||||
function handle(packet) {
|
||||
if (packet.applicationType && !rootActor) {
|
||||
rootActor = packet.from;
|
||||
send({ to: rootActor, type: "listTabs" });
|
||||
return;
|
||||
}
|
||||
if (packet.tabs && !consoleActor) {
|
||||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||||
send({ to: consoleActor, type: "startListeners", listeners: ["PageError", "ConsoleAPI"] });
|
||||
return;
|
||||
}
|
||||
if (packet.type === "pageError" && packet.pageError && packet.pageError.warning !== true && packet.pageError.category !== "CSS Parser") {
|
||||
const message = String(packet.pageError.errorMessage || packet.pageError.message || "").trim();
|
||||
if (message || packet.pageError.stacktrace) browserErrors.push(`PAGE: ${message || "unknown page error"}`);
|
||||
return;
|
||||
}
|
||||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||||
const message = (packet.message.arguments || []).map(formatArgument).join(" ") || "error";
|
||||
if (!message.includes("validateDOMNesting") && !message.includes("init intent failed") && !message.includes("ResizeObserver loop") && message !== "[object Object]") {
|
||||
browserErrors.push(`CONSOLE: ${message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||||
const pending = pendingEvaluation;
|
||||
pendingEvaluation = null;
|
||||
if (packet.exception) pending.reject(new Error(packet.exceptionMessage || packet.exception?.description || String(packet.exception)));
|
||||
else pending.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
function consume() {
|
||||
while (true) {
|
||||
const colon = buffer.indexOf(58);
|
||||
if (colon < 0) return;
|
||||
const length = Number(buffer.subarray(0, colon).toString());
|
||||
if (buffer.length < colon + 1 + length) return;
|
||||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||||
buffer = buffer.subarray(colon + 1 + length);
|
||||
handle(packet);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
consume();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
while (!consoleActor) await sleep(50);
|
||||
|
||||
async function waitFor(expression, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await evaluate(expression)) return true;
|
||||
} catch {
|
||||
// A reload briefly invalidates the old evaluation context.
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const layout of layouts) {
|
||||
for (const theme of themes) {
|
||||
const errorStart = browserErrors.length;
|
||||
let detail = true;
|
||||
try {
|
||||
const settings = JSON.stringify({
|
||||
notification: { enabled: true },
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
artifactDirectoryModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
fontScale: 1,
|
||||
layoutMode: layout,
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
const url = new URL("#/page/workspace/chats/new", baseUrl).href;
|
||||
try {
|
||||
await evaluate(`(function(){localStorage.setItem('strategy-theme',${JSON.stringify(theme)});localStorage.setItem('strategy-theme-user-set','1');localStorage.setItem('deerflow.local-settings',${JSON.stringify(settings)});sessionStorage.removeItem('embed.session');location.href=${JSON.stringify(url)};location.reload();return true})()`);
|
||||
} catch {
|
||||
// A successful full reload can destroy the old console request.
|
||||
}
|
||||
// The hash route is identical for every matrix item. Give Firefox time
|
||||
// to tear down the previous document before accepting a ready DOM.
|
||||
await sleep(700);
|
||||
const ready = await waitFor(`(function(){var root=document.getElementById('root'),textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');return location.hash.indexOf('#/page/workspace/chats/new')===0&&document.readyState==='complete'&&!!root&&root.getBoundingClientRect().width>0&&!!textarea&&textarea.getBoundingClientRect().height>0})()`);
|
||||
if (!ready) {
|
||||
detail = "页面未在 30 秒内完成渲染";
|
||||
} else {
|
||||
const state = await evaluate(`(function(){var html=document.documentElement,root=document.getElementById('root'),textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]'),style=getComputedStyle(html),sidebar=document.querySelector('[data-sidebar="sidebar"],[data-compact-guide="sidebar-nav"]');return JSON.stringify({classes:html.className,dataTheme:html.getAttribute('data-theme')||'',themeMode:html.getAttribute('theme-mode')||'',storedTheme:localStorage.getItem('strategy-theme'),layout:JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}').display.layoutMode,rootWidth:Math.round(root.getBoundingClientRect().width),inputWidth:Math.round(textarea.getBoundingClientRect().width),sidebarVisible:!!sidebar&&sidebar.getBoundingClientRect().width>0,background:style.getPropertyValue('--background').trim(),foreground:style.getPropertyValue('--foreground').trim(),primary:style.getPropertyValue('--primary').trim()})})()`);
|
||||
const parsed = JSON.parse(state);
|
||||
const themeApplied = parsed.storedTheme === theme && parsed.classes.split(/\s+/).includes(theme);
|
||||
const modeApplied = theme === "dark"
|
||||
? parsed.themeMode === "dark" && parsed.classes.split(/\s+/).includes("dark")
|
||||
: theme === "light"
|
||||
? parsed.dataTheme === "" && parsed.themeMode === ""
|
||||
: parsed.dataTheme === theme && parsed.themeMode === "";
|
||||
const tokensApplied = parsed.background && parsed.foreground && parsed.primary;
|
||||
const layoutApplied = parsed.layout === layout;
|
||||
const dimensionsHealthy = parsed.rootWidth > 0 && parsed.inputWidth > 200;
|
||||
if (!themeApplied || !modeApplied || !tokensApplied || !layoutApplied || !dimensionsHealthy) detail = state;
|
||||
}
|
||||
} catch (error) {
|
||||
detail = String(error);
|
||||
}
|
||||
await sleep(150);
|
||||
const errors = browserErrors.slice(errorStart);
|
||||
const pass = detail === true && errors.length === 0;
|
||||
results.push({ theme, layout, pass, detail, errors });
|
||||
console.log(`${pass ? "PASS" : "FAIL"} ${layout}/${theme}${pass ? "" : ` — ${detail}`}`);
|
||||
for (const error of errors) console.log(` ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const viewport = await evaluate(`JSON.stringify({innerWidth:window.innerWidth,innerHeight:window.innerHeight,devicePixelRatio:window.devicePixelRatio,screenWidth:screen.width,screenHeight:screen.height})`);
|
||||
socket.end();
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
console.log(`\nFirefox 52 theme/layout smoke: ${results.length - failed.length}/${results.length} passed`);
|
||||
console.log(`Viewport: ${viewport}`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
@@ -0,0 +1,294 @@
|
||||
import net from "node:net";
|
||||
|
||||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||||
const liveOnly = process.argv.includes("--live-only");
|
||||
const liveMode = liveOnly || process.argv.includes("--live");
|
||||
const liveChatHash = process.env.FIREFOX_LIVE_CHAT_HASH || "#/page/workspace/chats/new";
|
||||
const liveChatName = process.env.FIREFOX_LIVE_CHAT_NAME || "真实流式问答";
|
||||
const liveThreadPrefix = liveChatHash.replace(/\/new(?:\?.*)?$/, "/");
|
||||
const smokeFilter = process.env.FIREFOX_SMOKE_FILTER
|
||||
? new RegExp(process.env.FIREFOX_SMOKE_FILTER, "i")
|
||||
: null;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const routes = [
|
||||
["新对话", "#/page/workspace/chats/new", `!!document.querySelector('textarea[placeholder="今天我能为你做些什么?"]')`],
|
||||
["对话", "#/page/workspace/chats", `!!document.querySelector('input[type=search]')`],
|
||||
["我的项目", "#/page/strategy/qa/projects", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='新建项目'})`],
|
||||
["AI 写作", "#/page/workspace/ai-writing", `location.hash.indexOf('#/page/workspace/ai-writing')===0&&!!document.querySelector('textarea[placeholder="今天我能为你做些什么?"]')`],
|
||||
["多智能体规划", "#/page/workspace/roundtable/planning", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='业务链条'})`],
|
||||
["定时任务", "#/page/workspace/scheduled/tasks", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='新建'})`],
|
||||
["md 转 Word", "#/page/workspace/knowledge/markdown-editor", `!!document.querySelector('.cm-editor')`],
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const browserErrors = [];
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let rootActor;
|
||||
let consoleActor;
|
||||
let pendingEvaluation;
|
||||
|
||||
function send(packet) {
|
||||
const body = Buffer.from(JSON.stringify(packet));
|
||||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingEvaluation = { expression, resolve, reject };
|
||||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||||
});
|
||||
}
|
||||
|
||||
function handle(packet) {
|
||||
if (packet.applicationType && !rootActor) {
|
||||
rootActor = packet.from;
|
||||
send({ to: rootActor, type: "listTabs" });
|
||||
return;
|
||||
}
|
||||
if (packet.tabs && !consoleActor) {
|
||||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||||
send({
|
||||
to: consoleActor,
|
||||
type: "startListeners",
|
||||
listeners: ["PageError", "ConsoleAPI"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
packet.type === "pageError" &&
|
||||
packet.pageError?.error &&
|
||||
packet.pageError.category !== "CSS Parser"
|
||||
) {
|
||||
browserErrors.push(`PAGE: ${packet.pageError.errorMessage}`);
|
||||
return;
|
||||
}
|
||||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||||
const message = String(packet.message.arguments?.[0] ?? "error");
|
||||
// Existing scheduled-task cards contain a nested action button. React's
|
||||
// development-only warning is unrelated to Firefox execution.
|
||||
if (
|
||||
!message.includes("validateDOMNesting") &&
|
||||
!message.includes("init intent failed") &&
|
||||
message !== "Unhandled promise rejection" &&
|
||||
message !== "[object Object]"
|
||||
) {
|
||||
browserErrors.push(`CONSOLE: ${message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||||
const pending = pendingEvaluation;
|
||||
pendingEvaluation = null;
|
||||
if (packet.exception) {
|
||||
const detail =
|
||||
packet.exceptionMessage ||
|
||||
packet.exception?.preview?.message ||
|
||||
packet.exception?.description ||
|
||||
packet.exception?.class ||
|
||||
String(packet.exception);
|
||||
pending.reject(new Error(String(detail)));
|
||||
}
|
||||
else pending.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
function consume() {
|
||||
while (true) {
|
||||
const colon = buffer.indexOf(58);
|
||||
if (colon < 0) return;
|
||||
const length = Number(buffer.subarray(0, colon).toString());
|
||||
if (buffer.length < colon + 1 + length) return;
|
||||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||||
buffer = buffer.subarray(colon + 1 + length);
|
||||
handle(packet);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
consume();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
while (!consoleActor) await sleep(50);
|
||||
|
||||
async function waitFor(expression, timeoutMs = 20_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate(expression)) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigate(hash, readyExpression = "true") {
|
||||
const url = new URL(hash, baseUrl).href;
|
||||
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)};if(location.href===url)location.reload();else location.href=url;return 'navigate'})()`);
|
||||
// HashRouter keeps the previous route mounted for a short time after the URL
|
||||
// changes. Do not mistake that stale DOM for the destination being ready.
|
||||
await sleep(700);
|
||||
return waitFor(`(function(){var root=document.getElementById('root');return location.hash.indexOf(${JSON.stringify(hash)})===0&&document.readyState==='complete'&&!!root&&root.innerHTML.length>100&&document.body.innerText.length>20&&(${readyExpression})})()`);
|
||||
}
|
||||
|
||||
const mouseClick = `(function(element){if(!element)return false;['mousemove','mousedown','mouseup','click'].forEach(function(type){element.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type==='mousedown'?1:0}))});return true})`;
|
||||
const clickText = (text, prefix = false) =>
|
||||
`(function(){var text=${JSON.stringify(text)},items=[].slice.call(document.querySelectorAll('button,[role=menuitem],[role=tab]'));var element=items.filter(function(item){var value=(item.innerText||'').trim(),rect=item.getBoundingClientRect(),style=getComputedStyle(item);return rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'&&${prefix ? "value.indexOf(text)===0" : "value===text"}})[0];return ${mouseClick}(element)})()`;
|
||||
const clickTitle = (title) =>
|
||||
`(function(){var title=${JSON.stringify(title)},element=[].slice.call(document.querySelectorAll('[title]')).filter(function(item){return item.getAttribute('title')===title})[0];return ${mouseClick}(element)})()`;
|
||||
const clickModelSelector = `(${mouseClick})([].slice.call(document.querySelectorAll('[data-firefox52-smoke="model-selector"]')).filter(function(item){var rect=item.getBoundingClientRect();return rect.width>0&&rect.height>0})[0])`;
|
||||
const visibleText = (selector, text) =>
|
||||
`(function(){var text=${JSON.stringify(text)};return [].slice.call(document.querySelectorAll(${JSON.stringify(selector)})).some(function(e){var r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'&&(e.innerText||'').indexOf(text)>=0})})()`;
|
||||
|
||||
async function check(name, operation) {
|
||||
if (smokeFilter && !smokeFilter.test(name)) return;
|
||||
const errorStart = browserErrors.length;
|
||||
try {
|
||||
const detail = await operation();
|
||||
const errors = browserErrors.slice(errorStart);
|
||||
results.push({ name, pass: detail === true && errors.length === 0, detail, errors });
|
||||
} catch (error) {
|
||||
results.push({ name, pass: false, detail: String(error), errors: browserErrors.slice(errorStart) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!liveOnly) {
|
||||
for (const [name, hash, readyExpression] of routes) {
|
||||
await check(`页面:${name}`, async () => {
|
||||
if (!(await navigate(hash, readyExpression))) return "页面未在 10 秒内完成渲染";
|
||||
const healthy = await evaluate(`(function(){var root=document.getElementById('root');return !!root&&root.getBoundingClientRect().width>0&&root.getBoundingClientRect().height>0&&!/something went wrong|页面加载失败|发生错误/i.test(document.body.innerText)})()`);
|
||||
return healthy || "页面根节点尺寸或错误边界异常";
|
||||
});
|
||||
}
|
||||
|
||||
await check("设置和更多菜单", async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const settingsTrigger = `((${clickText("设置和更多")}))||((${clickTitle("设置和更多")}))`;
|
||||
if (!(await waitFor(settingsTrigger, 15_000))) return "未找到入口";
|
||||
return (await waitFor(visibleText("[role=menu],[data-slot=dropdown-menu-content]", "通知"))) || "菜单未打开";
|
||||
});
|
||||
|
||||
await check("换肤色板", async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const hovered = await evaluate(`(function(){var e=[].slice.call(document.querySelectorAll('[title]')).filter(function(x){return x.getAttribute('title')==='换肤'})[0];if(!e)return false;e.dispatchEvent(new MouseEvent('mouseover',{bubbles:true,view:window}));return true})()`);
|
||||
if (!hovered) return "未找到换肤入口";
|
||||
return (await waitFor(`[].slice.call(document.querySelectorAll('button[aria-label]')).filter(function(x){return ['红色','绿色','蓝色','冰川蓝','浅黄色','深蓝色'].indexOf(x.getAttribute('aria-label'))>=0&&x.getBoundingClientRect().width>0}).length>=6`)) || "色板未显示";
|
||||
});
|
||||
|
||||
for (const [name, button, expected] of [
|
||||
["Agent 选择器", "选择 Agent", "选择 Agent"],
|
||||
["模型选择器", "__model_selector__", "Model Selector"],
|
||||
["常用提示词管理", null, "管理常用提示词"],
|
||||
]) {
|
||||
await check(name, async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const clicked = await evaluate(
|
||||
button === "__model_selector__"
|
||||
? clickModelSelector
|
||||
: button
|
||||
? clickText(button)
|
||||
: clickTitle("管理常用提示词"),
|
||||
);
|
||||
if (!clicked) return "未找到入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", expected))) || "弹窗未打开";
|
||||
});
|
||||
}
|
||||
|
||||
await check("输入框 Enter 提交", async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const submitted = await evaluate(`(function(){var textarea=document.querySelector('textarea'),form=textarea&&textarea.closest('form');if(!textarea||!form)return false;window.__firefox52SubmitCount=0;form.addEventListener('submit',function(event){event.preventDefault();window.__firefox52SubmitCount+=1},{capture:true,once:true});var setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(textarea,'Firefox 52 Enter smoke test');textarea.dispatchEvent(new Event('input',{bubbles:true}));textarea.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true,cancelable:true}));return true})()`);
|
||||
if (!submitted) return "未找到输入表单";
|
||||
return (await waitFor(`window.__firefox52SubmitCount===1`)) || "Enter 未触发表单提交";
|
||||
});
|
||||
|
||||
await check("对话类型筛选", async () => {
|
||||
await navigate("#/page/workspace/chats", routes[1][2]);
|
||||
if (!(await evaluate(clickText("多智能体会商", true)))) return "未找到筛选按钮";
|
||||
await sleep(300);
|
||||
return (await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect();return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|页面加载失败|发生错误/i.test(document.body.innerText)})()`)) || "筛选后页面异常";
|
||||
});
|
||||
|
||||
await check("项目创建模式", async () => {
|
||||
await navigate("#/page/strategy/qa/projects", routes[2][2]);
|
||||
if (!(await evaluate(clickText("新建项目")))) return "未找到新建项目";
|
||||
return (await waitFor(visibleText("[role=dialog]", "选择创建模式"))) || "创建模式弹窗未打开";
|
||||
});
|
||||
|
||||
await check("AI 写作模型选择", async () => {
|
||||
await navigate("#/page/workspace/ai-writing", routes[3][2]);
|
||||
if (!(await evaluate(clickModelSelector))) return "未找到模型入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", "Model Selector"))) || "模型弹窗未打开";
|
||||
});
|
||||
|
||||
await check("多智能体业务链条", async () => {
|
||||
await navigate("#/page/workspace/roundtable/planning", routes[4][2]);
|
||||
if (!(await evaluate(clickText("业务链条")))) return "未找到业务链条入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", "业务链条"))) || "业务链条弹窗未打开";
|
||||
});
|
||||
|
||||
await check("定时任务新建", async () => {
|
||||
await navigate("#/page/workspace/scheduled/tasks", routes[5][2]);
|
||||
if (!(await evaluate(clickText("新建")))) return "未找到新建入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", "新建定时任务"))) || "新建定时任务弹窗未打开";
|
||||
});
|
||||
|
||||
await check("md 转 Word 编辑器", async () => {
|
||||
await navigate("#/page/workspace/knowledge/markdown-editor", routes[6][2]);
|
||||
const ready = await evaluate(`!!document.querySelector('.cm-editor')&&!!document.querySelector('input[placeholder="请输入导出的 Word 标题"]')`);
|
||||
if (!ready) return "编辑器或标题输入框缺失";
|
||||
if (!(await evaluate(clickText("重置")))) return "重置按钮不可用";
|
||||
return true;
|
||||
});
|
||||
|
||||
await check("md 转 Word 实际生成", async () => {
|
||||
await navigate("#/page/workspace/knowledge/markdown-editor", routes[6][2]);
|
||||
const installed = await evaluate(`(function(){window.__firefox52Download=null;window.__firefox52DownloadNetwork=[];window.__firefox52DownloadNotices=[];new MutationObserver(function(){[].slice.call(document.querySelectorAll('[data-sonner-toast],[role="alert"],[role="status"]')).forEach(function(item){var value=(item.innerText||item.textContent||'').trim();if(value&&window.__firefox52DownloadNotices.indexOf(value)<0)window.__firefox52DownloadNotices.push(value)})}).observe(document.body,{childList:true,subtree:true});var originalFetch=window.fetch.bind(window);window.fetch=function(){var args=arguments,item={url:String(args[0]&&args[0].url||args[0]||''),status:'pending'};window.__firefox52DownloadNetwork.push(item);return originalFetch.apply(window,args).then(function(response){item.status=response.status;return response},function(error){item.status='error';item.error=String(error);throw error})};var originalCreate=URL.createObjectURL.bind(URL);URL.createObjectURL=function(blob){window.__firefox52Download={blobSize:blob.size,blobType:blob.type,download:''};return originalCreate(blob)};HTMLAnchorElement.prototype.click=function(){var state=window.__firefox52Download||{};state.download=this.download||'';state.href=this.href||'';window.__firefox52Download=state};return true})()`);
|
||||
if (!installed) return "无法安装下载捕获器";
|
||||
if (!(await evaluate(clickText("下载 Word")))) return "下载 Word 按钮不可用";
|
||||
const generated = await waitFor(`(function(){var item=window.__firefox52Download;return !!item&&item.blobSize>0&&/\\.docx$/i.test(item.download)&&item.blobType==='application/vnd.openxmlformats-officedocument.wordprocessingml.document'})()`, 60_000);
|
||||
if (generated) return true;
|
||||
return await evaluate(`JSON.stringify({download:window.__firefox52Download||null,network:window.__firefox52DownloadNetwork||[],notices:window.__firefox52DownloadNotices||[]})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (liveMode) {
|
||||
await check(liveChatName, async () => {
|
||||
await navigate(liveChatHash, routes[0][2]);
|
||||
const marker = `FIREFOX52_STREAM_OK_${Date.now()}`;
|
||||
const prompt = `请只回复这一行,不要添加其他内容:${marker}`;
|
||||
const tracing = await evaluate(`(function(){window.__firefox52Network=[];window.__firefox52RuntimeErrors=[];window.__firefox52Notices=[];window.addEventListener('error',function(event){window.__firefox52RuntimeErrors.push(String(event.message||event.error||'error'))});window.addEventListener('unhandledrejection',function(event){window.__firefox52RuntimeErrors.push(String(event.reason||'unhandled rejection'))});new MutationObserver(function(){[].slice.call(document.querySelectorAll('[data-sonner-toast],[role="alert"],[role="status"]')).forEach(function(item){var value=(item.innerText||item.textContent||'').trim();if(value&&window.__firefox52Notices.indexOf(value)<0)window.__firefox52Notices.push(value)})}).observe(document.body,{childList:true,subtree:true});if(!window.__firefox52OriginalFetch){window.__firefox52OriginalFetch=window.fetch;window.fetch=function(){var args=arguments,url=String(args[0]&&args[0].url||args[0]||''),init=args[1]||{};window.__firefox52Network.push({kind:'fetch',method:String(init.method||args[0]&&args[0].method||'GET'),url:url,status:'pending'});var item=window.__firefox52Network[window.__firefox52Network.length-1];return window.__firefox52OriginalFetch.apply(this,args).then(function(response){item.status=response.status;if(item.method==='POST'&&item.url.slice(-12)==='/api/threads'){try{response.clone().text().then(function(body){item.body=body.slice(0,500)},function(error){item.cloneError=String(error)})}catch(error){item.cloneError=String(error)}}return response},function(error){item.status='error';item.error=String(error);throw error})}}if(!window.__firefox52OriginalXhrOpen){window.__firefox52OriginalXhrOpen=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){this.__firefox52Trace={kind:'xhr',method:String(method),url:String(url),status:'pending'};window.__firefox52Network.push(this.__firefox52Trace);this.addEventListener('loadend',function(){this.__firefox52Trace.status=this.status});return window.__firefox52OriginalXhrOpen.apply(this,arguments)}}return true})()`);
|
||||
if (!tracing) return "无法安装请求追踪器";
|
||||
const filled = await evaluate(`(function(){var textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');if(!textarea)return false;var setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(textarea,${JSON.stringify(prompt)});textarea.dispatchEvent(new Event('input',{bubbles:true}));return textarea.value===${JSON.stringify(prompt)}})()`);
|
||||
if (!filled) return "未找到问答输入框";
|
||||
// Keep Enter compatibility as its own assertion above. The live check
|
||||
// clicks the real submit control so failures are attributable to the
|
||||
// backend stream rather than to synthetic keyboard-event differences.
|
||||
await sleep(300);
|
||||
const sent = await evaluate(`(function(){var buttons=[].slice.call(document.querySelectorAll('button[aria-label="Submit"]')),button=buttons.filter(function(item){var rect=item.getBoundingClientRect();return rect.width>0&&rect.height>0})[0];if(!button||button.disabled)return false;var form=button.form;if(!form)return false;window.__firefox52SubmitCount=0;form.addEventListener('submit',function(){window.__firefox52SubmitCount+=1},{capture:true});button.click();return true})()`);
|
||||
if (!sent) return "发送按钮不存在或仍处于禁用状态";
|
||||
const enteredThread = await waitFor(`location.hash.indexOf(${JSON.stringify(liveThreadPrefix)})===0&&location.hash.indexOf('/new')<0`, 45_000);
|
||||
if (!enteredThread) {
|
||||
const diagnostic = await evaluate(`(function(){var textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');return JSON.stringify({hash:location.hash,submitCount:window.__firefox52SubmitCount||0,textarea:textarea&&textarea.value,markerVisible:(document.body.innerText||'').indexOf(${JSON.stringify(marker)})>=0,lastAgent:localStorage.getItem('deerflow.last-agent-id'),pendingAgentMessage:sessionStorage.getItem('pendingAgentMessage'),notices:window.__firefox52Notices||[],runtimeErrors:window.__firefox52RuntimeErrors||[],network:(window.__firefox52Network||[]).slice(-12),text:(document.body.innerText||'').slice(-800)})})()`);
|
||||
return `发送后未进入会话路由:${diagnostic}`;
|
||||
}
|
||||
const streamed = await waitFor(`(function(){var marker=${JSON.stringify(marker)},text=document.body.innerText||'',count=text.split(marker).length-1;return count>=2&&!!document.querySelector('[data-reference-batch-id]')})()`, 180_000);
|
||||
return streamed || "180 秒内未收到包含校验标记的流式回答";
|
||||
});
|
||||
}
|
||||
|
||||
socket.end();
|
||||
|
||||
for (const result of results) {
|
||||
const mark = result.pass ? "PASS" : "FAIL";
|
||||
console.log(`${mark} ${result.name}${result.pass ? "" : ` — ${result.detail}`}`);
|
||||
for (const error of result.errors) console.log(` ${error}`);
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
console.log(`\nFirefox 52 smoke: ${results.length - failed.length}/${results.length} passed`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
// Resolve Vite's installed dependency directly: pnpm does not expose a
|
||||
// transitive esbuild binary in node_modules/.bin, and tests must run offline.
|
||||
const viteRequire = createRequire(require.resolve("vite/package.json"));
|
||||
const { build, stop } = viteRequire("esbuild");
|
||||
const testEntries = {
|
||||
"login-attempt": "src/core/auth/login-attempt.test.ts",
|
||||
"login-entry": "src/core/auth/login-entry.test.ts",
|
||||
"session-validation": "src/core/auth/session-validation.test.ts",
|
||||
"auth-request-policy": "src/core/api/auth-request-policy.test.ts",
|
||||
};
|
||||
const outputDirectory = path.join(root, ".runtime/unit-tests");
|
||||
|
||||
try {
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: testEntries,
|
||||
outdir: outputDirectory,
|
||||
outExtension: { ".js": ".mjs" },
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
});
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ["--test", ...Object.keys(testEntries).map((name) => path.join(outputDirectory, `${name}.mjs`))], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
@@ -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;
|
||||
+79
-112
@@ -1,18 +1,15 @@
|
||||
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 { 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 {
|
||||
@@ -28,46 +25,28 @@ import {
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage, {
|
||||
PublicScheduledTaskFullscreenPage,
|
||||
} from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||
|
||||
const LandingPage = lazy(() => import("./pages/LandingPage"));
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage"));
|
||||
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
|
||||
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
|
||||
const PublicScheduledTaskFullscreenPage = lazy(() =>
|
||||
import("./pages/PublicScheduledTaskPage").then((module) => ({
|
||||
default: module.PublicScheduledTaskFullscreenPage,
|
||||
})),
|
||||
);
|
||||
const PublicSharePage = lazy(() => import("./pages/PublicSharePage"));
|
||||
const PublicLeaderboardPage = lazy(() => import("./pages/PublicLeaderboardPage"));
|
||||
const PublicRoundtableSharePage = lazy(() => import("./pages/PublicRoundtableSharePage"));
|
||||
const EmbedChatPage = lazy(() => import("./pages/EmbedChatPage"));
|
||||
const EmbedRoundtablePage = lazy(() => import("./pages/EmbedRoundtablePage"));
|
||||
const SiteStudioPage = lazy(() => import("./pages/site-studio/SiteStudioPage"));
|
||||
const DashboardStandalonePage = lazy(
|
||||
() => import("@/roundtable-planning/pages/DashboardStandalonePage"),
|
||||
);
|
||||
const PositionRoundtablePage = lazy(
|
||||
() => import("@/position-roundtable/pages/PositionRoundtablePage"),
|
||||
);
|
||||
const WeKnoraWikiPageView = lazy(
|
||||
() => import("@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"),
|
||||
);
|
||||
const ChatRuntime = lazy(() =>
|
||||
import("@/components/workspace/chat-runtime").then((module) => ({
|
||||
default: module.ChatRuntime,
|
||||
})),
|
||||
);
|
||||
const NotFoundPage = lazy(() =>
|
||||
import("./pages/NotFoundPage").then((module) => ({
|
||||
default: module.NotFoundPage,
|
||||
})),
|
||||
);
|
||||
const OfficialDeerFlowEntry = lazy(() => import("./pages/OfficialDeerFlowEntry"));
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
@@ -93,10 +72,6 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
void fetchYLogConfig();
|
||||
}, [positionStandaloneMode]);
|
||||
const forcedTheme = useMemo(() => {
|
||||
// b5-1 任务分析深链最高优先:接入方传 ?theme= 用传入值,不传默认深蓝。
|
||||
// 先于 embed 判定,避免 embed=1 缺省 light 盖掉本页的深蓝默认。
|
||||
const b51Theme = resolveB51ForcedTheme(location.pathname, location.search);
|
||||
if (b51Theme) return b51Theme;
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
|
||||
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
|
||||
const embedTheme = resolveForcedEmbedTheme(location.search);
|
||||
@@ -170,80 +145,72 @@ export function App() {
|
||||
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
|
||||
);
|
||||
const pageElement = positionStandaloneMode ? (
|
||||
<RequireAccount allowLegacyAnonymous>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome />
|
||||
</ChatRuntime>
|
||||
</RequireAccount>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome />
|
||||
</ChatRuntime>
|
||||
) : (
|
||||
<RequireAccount><PageRoutes /></RequireAccount>
|
||||
<PageRoutes />
|
||||
);
|
||||
|
||||
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 />} />
|
||||
<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={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/login/:username"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode"
|
||||
element={<PublicRoundtableSharePage view="landing" />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/report"
|
||||
element={<PublicRoundtableSharePage view="report" />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/dashboard"
|
||||
element={<PublicRoundtableSharePage view="dashboard" />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
|
||||
element={<PublicScheduledTaskFullscreenPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/embed/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EmbedChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
|
||||
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
@@ -110,12 +110,12 @@ export const CodeBlock = ({
|
||||
>
|
||||
<div className="relative size-full">
|
||||
<div
|
||||
className="[&>pre]:bg-background! [&>pre]:text-foreground! size-full overflow-auto dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
|
||||
className="[&>pre]:!bg-background [&>pre]:!text-foreground size-full overflow-auto dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div
|
||||
className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden size-full overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
|
||||
className="[&>pre]:!bg-background [&>pre]:!text-foreground hidden size-full overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
|
||||
@@ -9,8 +9,8 @@ export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!",
|
||||
"[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!",
|
||||
"bg-card gap-px overflow-hidden rounded-md border p-1 !shadow-none",
|
||||
"[&>button]:hover:!bg-secondary [&>button]:rounded-md [&>button]:!border-none [&>button]:!bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -36,7 +36,7 @@ export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("bg-secondary gap-0.5 rounded-t-md border-b p-3!", className)}
|
||||
className={cn("bg-secondary gap-0.5 rounded-t-md border-b !p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -65,7 +65,7 @@ export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("bg-secondary rounded-b-md border-t p-3!", className)}
|
||||
className={cn("bg-secondary rounded-b-md border-t !p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -65,8 +65,9 @@ import {
|
||||
type CompositionEventHandler,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
type HTMLAttributes,
|
||||
type FormEventHandler,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
@@ -1202,43 +1203,43 @@ export const PromptInputTools = ({
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||
|
||||
export const PromptInputButton = ({
|
||||
variant = "ghost",
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: PromptInputButtonProps) => {
|
||||
return (
|
||||
<InputGroupButton
|
||||
className={cn(className)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||
|
||||
export const PromptInputButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PromptInputButtonProps
|
||||
>(({ variant = "ghost", className, size, ...props }, ref) => (
|
||||
<InputGroupButton
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
PromptInputButton.displayName = "PromptInputButton";
|
||||
|
||||
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
||||
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
||||
<DropdownMenu {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
||||
|
||||
export const PromptInputActionMenuTrigger = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputActionMenuTriggerProps) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton className={className} {...props}>
|
||||
{children ?? <PlusIcon className="size-4" />}
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
||||
|
||||
export const PromptInputActionMenuTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PromptInputActionMenuTriggerProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton ref={ref} className={className} {...props}>
|
||||
{children ?? <PlusIcon className="size-4" />}
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
));
|
||||
|
||||
PromptInputActionMenuTrigger.displayName = "PromptInputActionMenuTrigger";
|
||||
|
||||
export type PromptInputActionMenuContentProps = ComponentProps<
|
||||
typeof DropdownMenuContent
|
||||
@@ -1274,24 +1275,20 @@ export const PromptInputSubmit = ({
|
||||
status,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
let ariaLabel = "发送消息";
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
ariaLabel = "正在发送";
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
ariaLabel = "停止生成";
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
ariaLabel = "重新发送";
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label={ariaLabel}
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
status === "streaming" &&
|
||||
|
||||
@@ -31,11 +31,6 @@ 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";
|
||||
|
||||
// ── 适配层 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -91,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 {
|
||||
@@ -214,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("");
|
||||
@@ -343,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("上传失败,请重试");
|
||||
@@ -396,6 +410,7 @@ const DocumentUploadPopupContent: FC<{
|
||||
selectedSearchSources.map((s) => ({
|
||||
id: undefined,
|
||||
title: s.title,
|
||||
task_id: undefined,
|
||||
status: "success" as const,
|
||||
}))
|
||||
);
|
||||
@@ -599,8 +614,6 @@ const DocumentUploadPopupContent: FC<{
|
||||
};
|
||||
|
||||
const yUserId = getYUserId();
|
||||
const userToken = getStoredAuth()?.access_token;
|
||||
const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -674,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}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -682,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,19 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
applyFontScaleToDocument,
|
||||
normalizeFontScale,
|
||||
normalizeFontScaleMode,
|
||||
} from "@/core/ui/page-zoom";
|
||||
|
||||
/**
|
||||
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
||||
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
|
||||
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
|
||||
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
|
||||
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
|
||||
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
|
||||
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
|
||||
* `fontScale === 1` 即默认 16px。
|
||||
*/
|
||||
export const BASE_FONT_PX = 16;
|
||||
@@ -26,36 +21,25 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
|
||||
{ value: 1.25, label: "特大", px: 20 },
|
||||
];
|
||||
|
||||
export const FONT_SCALE_MODE_OPTIONS: {
|
||||
value: "text" | "page";
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "text",
|
||||
label: "仅文字",
|
||||
description: "只放大文字,间距和布局保持原样。",
|
||||
},
|
||||
{
|
||||
value: "page",
|
||||
label: "整页缩放",
|
||||
description: "文字、间距、图标和写死的像素一起放大。",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
|
||||
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
|
||||
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
|
||||
* 不渲染任何 DOM。
|
||||
*/
|
||||
export function FontScaleApplier() {
|
||||
const [settings] = useLocalSettings();
|
||||
const { fontScale, fontScaleMode } = settings.display;
|
||||
const { fontScale } = settings.display;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applyFontScaleToDocument(
|
||||
normalizeFontScaleMode(fontScaleMode),
|
||||
normalizeFontScale(fontScale),
|
||||
);
|
||||
}, [fontScale, fontScaleMode]);
|
||||
useEffect(() => {
|
||||
const scale =
|
||||
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
|
||||
? fontScale
|
||||
: 1;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--ui-font-scale", String(scale));
|
||||
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
|
||||
root.style.removeProperty("font-size");
|
||||
}, [fontScale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { toCssPx } from "@/core/ui/page-zoom";
|
||||
|
||||
import type { TooltipState } from "./graphTypes";
|
||||
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
||||
|
||||
@@ -11,8 +9,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
|
||||
if (!tooltip) return null;
|
||||
const meta = NODE_KIND_META[tooltip.kind];
|
||||
// Keep the tooltip inside the viewport.
|
||||
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
|
||||
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
|
||||
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
|
||||
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
|
||||
|
||||
return (
|
||||
<div className="kg-tooltip" style={{ left, top }}>
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 98px;
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||
height: calc(100vh - 4rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||
.sidebar-contnet--header-hidden {
|
||||
top: 0;
|
||||
height: calc(100vh / var(--df-page-zoom, 1));
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-contnet--header-hidden .item-nav-content {
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||
height: calc(100vh - 20px);
|
||||
min-height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
@@ -160,8 +160,8 @@
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||
height: calc(100vh - 4rem - 20px);
|
||||
min-height: calc(100vh - 4rem - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 96px;
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||
height: calc(100vh - 4rem);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #ffffff;
|
||||
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||
.light-sidebar--header-hidden {
|
||||
top: 0;
|
||||
height: calc(100vh / var(--df-page-zoom, 1));
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||
|
||||
@@ -194,7 +194,7 @@ export function PageSidebar({
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
|
||||
@@ -500,9 +500,7 @@ function LegacyAccordionSidebar({
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
headerHidden
|
||||
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
|
||||
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
|
||||
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
|
||||
} ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -240,7 +240,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ const HoverCardContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-[var(--radix-hover-card-content-transform-origin)] rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -97,24 +97,22 @@ const inputGroupButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const InputGroupButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
Omit<React.ComponentPropsWithoutRef<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>
|
||||
>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
InputGroupButton.displayName = "InputGroupButton";
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
|
||||
@@ -151,8 +151,8 @@
|
||||
padding: 6px;
|
||||
background: radial-gradient(
|
||||
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
|
||||
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
|
||||
rgba(132, 0, 255, 0.8) 0%,
|
||||
rgba(132, 0, 255, 0.4) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
border-radius: inherit;
|
||||
@@ -165,7 +165,7 @@
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
opacity: var(--glow-intensity);
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -178,7 +178,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -195,7 +195,7 @@ function Sidebar({
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
className="bg-sidebar text-sidebar-foreground w-[var(--sidebar-width)] p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
@@ -226,25 +226,25 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]"
|
||||
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed bottom-0 z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex [top:var(--header-height,4rem)]",
|
||||
"fixed bottom-0 z-10 hidden w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex [top:var(--header-height,4rem)]",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -486,7 +486,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,box-shadow,transform] duration-200 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-2xl group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:data-[active=true]:bg-[#fff1e8] group-data-[collapsible=icon]:data-[active=true]:text-primary group-data-[collapsible=icon]:data-[active=true]:shadow-[inset_0_0_0_1px_rgba(217,33,33,0.12),0_10px_24px_-18px_rgba(217,33,33,0.42)] group-data-[collapsible=icon]:[&>span]:hidden group-data-[collapsible=icon]:[&>svg]:mx-auto group-data-[collapsible=icon]:[&>svg]:size-5 [&>span:last-child]:truncate [&>svg]:size-[18px] [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,box-shadow,transform] duration-200 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:!size-10 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-2xl group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:data-[active=true]:bg-[#fff1e8] group-data-[collapsible=icon]:data-[active=true]:text-primary group-data-[collapsible=icon]:data-[active=true]:shadow-[inset_0_0_0_1px_rgba(217,33,33,0.12),0_10px_24px_-18px_rgba(217,33,33,0.42)] group-data-[collapsible=icon]:[&>span]:hidden group-data-[collapsible=icon]:[&>svg]:mx-auto group-data-[collapsible=icon]:[&>svg]:size-5 [&>span:last-child]:truncate [&>svg]:size-[18px] [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -497,7 +497,7 @@ const sidebarMenuButtonVariants = cva(
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -649,7 +649,7 @@ function SidebarMenuSkeleton({
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
className="h-4 max-w-[var(--skeleton-width)] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -31,9 +31,9 @@ const tabsListVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-muted dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
"bg-muted dark:group-data-[orientation=horizontal]/tabs:!h-8 dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
line:
|
||||
"gap-1 bg-transparent dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-visible dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
"gap-1 bg-transparent dark:group-data-[orientation=horizontal]/tabs:!h-8 dark:items-stretch dark:gap-0 dark:overflow-visible dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -36,7 +36,7 @@ function ToggleGroup({
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
"group/toggle-group flex w-fit items-center gap-[calc(var(--gap)*0.25rem)] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -54,7 +54,7 @@ const TooltipContent = React.forwardRef<
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function MarkdownCodeEditor({
|
||||
highlightActiveLineGutter: false,
|
||||
lineNumbers: true,
|
||||
}}
|
||||
className="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-h-0 flex-col bg-muted/10">
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -19,11 +17,6 @@ export function UserManagementPage() {
|
||||
title={t.adminUsers.title}
|
||||
description={t.adminUsers.description}
|
||||
>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
BotIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
@@ -93,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 {
|
||||
@@ -209,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();
|
||||
@@ -226,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 ?? "");
|
||||
@@ -604,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"
|
||||
@@ -670,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}`,
|
||||
@@ -692,7 +677,7 @@ export function AgentCard({
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{agent.skill_labels?.[skill]?.trim() || desensitize(skill)}
|
||||
{skillNameZhMap.get(skill) ?? desensitize(skill)}
|
||||
</Badge>
|
||||
),
|
||||
})),
|
||||
@@ -798,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"
|
||||
@@ -871,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">
|
||||
@@ -935,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">
|
||||
@@ -1006,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}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
|
||||
|
||||
return (
|
||||
// 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。
|
||||
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
|
||||
<aside className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
|
||||
{/* 智能体(上半,可收起)。展开时:标题栏 + 来源下拉/名称搜索 + 列表;
|
||||
收起时只留标题栏,下方「最近的对话」占满剩余空间。 */}
|
||||
<div
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -291,7 +291,7 @@ export function AgentModuleSidebar({
|
||||
// 浅色保持白底。其余颜色已走语义 token 自适配。
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
|
||||
"flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { 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);
|
||||
@@ -135,8 +138,8 @@ export function AgentSelector({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-full border !px-3 shadow-none transition-colors",
|
||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
compact
|
||||
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
: selectedAgent
|
||||
|
||||
@@ -66,6 +66,11 @@ import {
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
completeArtifactRewriteFallback,
|
||||
primeArtifactRewriteFallback,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
@@ -446,15 +451,15 @@ export function ArtifactFileDetail({
|
||||
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
|
||||
return () => setGeneratingArtifact(owner, null);
|
||||
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||||
? draftContent
|
||||
: (content ?? ""));
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||||
? draftContent
|
||||
: (content ?? ""));
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||||
const sandboxDisplayText = useMemo(() => {
|
||||
const raw = displayContent ?? "";
|
||||
if (disableDesensitize) return raw;
|
||||
@@ -477,8 +482,8 @@ export function ArtifactFileDetail({
|
||||
!isArtifactContentLoading;
|
||||
const isAutoPreviewLanguage =
|
||||
language === "html" || language === "markdown" || language === "mermaid";
|
||||
const isPreviewStreaming =
|
||||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||
const isPreviewStreaming =
|
||||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
@@ -503,16 +508,16 @@ export function ArtifactFileDetail({
|
||||
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentRewriteActive) return;
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
}, [documentRewriteActive]);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentRewriteActive) return;
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
}, [documentRewriteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== "edit" || canUseAiEditor) return;
|
||||
@@ -557,20 +562,13 @@ export function ArtifactFileDetail({
|
||||
presentationId: string,
|
||||
event: DocumentRewriteStreamEvent,
|
||||
) => {
|
||||
if (!rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "job_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
jobId: event.jobId,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
});
|
||||
break;
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
originalCharCount: event.originalCharCount,
|
||||
if (!rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
originalCharCount: event.originalCharCount,
|
||||
requirementsPlan: "",
|
||||
});
|
||||
break;
|
||||
@@ -631,11 +629,10 @@ export function ArtifactFileDetail({
|
||||
case "commit_started":
|
||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
versionId: event.versionId ?? null,
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
break;
|
||||
@@ -651,13 +648,9 @@ export function ArtifactFileDetail({
|
||||
case "cancelled":
|
||||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||
break;
|
||||
case "error":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
diagnostic: event.diagnostic ?? null,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
console.error("AI 全文改写历史恢复失败:", event.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -679,18 +672,10 @@ export function ArtifactFileDetail({
|
||||
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||||
? historicalResult.summary as DocumentRewriteSummary
|
||||
: null;
|
||||
const historicalPresentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
jobId: historical.id,
|
||||
outputPath: typeof historicalResult.outputPath === "string"
|
||||
? historicalResult.outputPath
|
||||
: historical.outputPath,
|
||||
fileName: getFileName(
|
||||
typeof historicalResult.outputPath === "string"
|
||||
? historicalResult.outputPath
|
||||
: historical.outputPath ?? filepath,
|
||||
),
|
||||
const historicalPresentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
instruction: historical.request.instruction,
|
||||
style: historical.request.style,
|
||||
modelName: historical.request.modelName,
|
||||
@@ -711,14 +696,11 @@ export function ArtifactFileDetail({
|
||||
summary: historicalSummary,
|
||||
warnings: historicalSummary?.validationWarnings ?? [],
|
||||
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||||
error: historicalResult.status === "conflict"
|
||||
? "文件在改写期间已变化,未覆盖原文件。"
|
||||
: historical.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: historical.status === "failed"
|
||||
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
|
||||
: null,
|
||||
diagnostic: historical.diagnostic ?? null,
|
||||
error: historicalResult.status === "conflict"
|
||||
? "文件在改写期间已变化,未覆盖原文件。"
|
||||
: historical.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
||||
});
|
||||
}
|
||||
const result = latest.result;
|
||||
@@ -727,25 +709,36 @@ export function ArtifactFileDetail({
|
||||
: null;
|
||||
const originalContent = latest.request.originalContent || draftContent;
|
||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||
const outputPath = typeof result.outputPath === "string"
|
||||
? result.outputPath
|
||||
: latest.outputPath;
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
jobId: latest.id,
|
||||
outputPath,
|
||||
fileName: getFileName(outputPath ?? filepath),
|
||||
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
const localFallbackAvailable =
|
||||
versionSnapshotFailed &&
|
||||
latest.status === "completed" &&
|
||||
result.status !== "conflict" &&
|
||||
committedHash !== null &&
|
||||
completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
instruction: latest.request.instruction,
|
||||
style: latest.request.style,
|
||||
modelName: latest.request.modelName,
|
||||
generateImages: latest.request.generateImages,
|
||||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||
originalContent,
|
||||
rewrittenContent,
|
||||
committedHash,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
rewrittenContent,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
});
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
setDocumentRewritePresentationId(presentationId);
|
||||
@@ -754,22 +747,32 @@ export function ArtifactFileDetail({
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: result.status === "conflict" ? "conflict" : "completed",
|
||||
summary,
|
||||
warnings: summary?.validationWarnings ?? [],
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
committedHash,
|
||||
error: result.status === "conflict" ? "改写版文件未生成。" : null,
|
||||
});
|
||||
return;
|
||||
warnings: summary?.validationWarnings ?? [],
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
||||
});
|
||||
if (rewrittenContent && result.status !== "conflict") {
|
||||
setDraftContent(rewrittenContent);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewrittenContent, url: undefined },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||
summary,
|
||||
error: latest.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
|
||||
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
|
||||
});
|
||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||
summary,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -791,22 +794,35 @@ export function ArtifactFileDetail({
|
||||
signal,
|
||||
),
|
||||
onEvent: (event) => updatePresentation(presentationId, event),
|
||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
});
|
||||
},
|
||||
onFailure: ({ error, diagnostic }) => {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error,
|
||||
diagnostic: diagnostic ?? null,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
}
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
});
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Existing artifacts remain available even if history hydration fails.
|
||||
}
|
||||
@@ -929,8 +945,8 @@ export function ArtifactFileDetail({
|
||||
return;
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||
if (language === "html") {
|
||||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -940,9 +956,9 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
isPreviewStreaming,
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
isPreviewStreaming,
|
||||
isSupportPreview,
|
||||
language,
|
||||
writeFileInFlight,
|
||||
@@ -953,7 +969,7 @@ export function ArtifactFileDetail({
|
||||
return;
|
||||
}
|
||||
|
||||
if (writeFileInFlight || documentRewriteActive) {
|
||||
if (writeFileInFlight || documentRewriteActive) {
|
||||
setViewMode("code");
|
||||
return;
|
||||
}
|
||||
@@ -965,10 +981,10 @@ export function ArtifactFileDetail({
|
||||
}, 1400);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [
|
||||
allowAutoViewSwitch,
|
||||
documentRewriteActive,
|
||||
isAutoPreviewLanguage,
|
||||
}, [
|
||||
allowAutoViewSwitch,
|
||||
documentRewriteActive,
|
||||
isAutoPreviewLanguage,
|
||||
isSupportPreview,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
@@ -1129,17 +1145,16 @@ export function ArtifactFileDetail({
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => {
|
||||
if (!canEditArtifact) {
|
||||
toast.error("当前文件暂不支持全文改写。");
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
|
||||
// Do not make users manually save before using AI rewrite. Persist their
|
||||
if (!canEditArtifact) {
|
||||
toast.error("当前文件暂不支持全文改写。");
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setViewMode("code");
|
||||
|
||||
// Do not make users manually save before using AI rewrite. Persist their
|
||||
// current draft first so the rewrite starts from exactly what they see,
|
||||
// and the optimistic hash remains valid on the server.
|
||||
if (hasLocalEdits) {
|
||||
@@ -1158,7 +1173,12 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}
|
||||
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
primeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
});
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
@@ -1190,21 +1210,14 @@ export function ArtifactFileDetail({
|
||||
expectedHash,
|
||||
},
|
||||
{
|
||||
onEvent: (event) => {
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "job_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
jobId: event.jobId,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
});
|
||||
break;
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
originalCharCount: event.originalCharCount,
|
||||
onEvent: (event) => {
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
originalCharCount: event.originalCharCount,
|
||||
requirementsPlan: "",
|
||||
});
|
||||
break;
|
||||
@@ -1268,11 +1281,9 @@ export function ArtifactFileDetail({
|
||||
case "commit_started":
|
||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
versionId: event.versionId ?? null,
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
break;
|
||||
@@ -1282,48 +1293,77 @@ export function ArtifactFileDetail({
|
||||
versionId: event.versionId ?? null,
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "conflict",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "cancelled":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "cancelled",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
diagnostic: event.diagnostic ?? null,
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "conflict",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
// The sandbox retains the visible draft and exposes manual
|
||||
// save instead of showing an error state to the user.
|
||||
console.error("AI 全文改写接口失败:", event.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
});
|
||||
}
|
||||
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
|
||||
},
|
||||
onFailure: ({ error, diagnostic }) => {
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error,
|
||||
diagnostic: diagnostic ?? null,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
committedHash,
|
||||
});
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
});
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
} else {
|
||||
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||
}
|
||||
},
|
||||
onFailure: ({ content, error }) => {
|
||||
// The rewrite transport can fail after the server has already
|
||||
// streamed text (or before the first token on a 404). Keep that
|
||||
// visible content in the sandbox, make Save actionable, and leave
|
||||
// diagnostics to the developer console only.
|
||||
const retainedContent = content || draftContent;
|
||||
setDraftContent(retainedContent);
|
||||
setHasLocalEdits(true);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: retainedContent, url: undefined },
|
||||
);
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "completed",
|
||||
rewrittenContent: retainedContent,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -1341,37 +1381,15 @@ export function ArtifactFileDetail({
|
||||
],
|
||||
);
|
||||
|
||||
const handleCancelDocumentRewrite = useCallback(() => {
|
||||
documentRewrite.cancel();
|
||||
const handleCancelDocumentRewrite = useCallback(() => {
|
||||
documentRewrite.cancel();
|
||||
if (documentRewritePresentationId && rewritePresentation) {
|
||||
rewritePresentation.update(documentRewritePresentationId, {
|
||||
stage: "cancelled",
|
||||
error: "已停止全文改写,原文件未被修改。",
|
||||
});
|
||||
}
|
||||
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||||
|
||||
// The docked conversation composer is outside this artifact pane. Register
|
||||
// the same cancellation path so its standard square stop button can abort
|
||||
// both the local SSE reader and the durable rewrite job.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!documentRewriteActive ||
|
||||
!documentRewritePresentationId ||
|
||||
!rewritePresentation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return rewritePresentation.registerCancellation(
|
||||
documentRewritePresentationId,
|
||||
handleCancelDocumentRewrite,
|
||||
);
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
documentRewritePresentationId,
|
||||
handleCancelDocumentRewrite,
|
||||
rewritePresentation,
|
||||
]);
|
||||
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||||
|
||||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||
@@ -1514,7 +1532,7 @@ export function ArtifactFileDetail({
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
) : hasMultipleArtifacts ? (
|
||||
<Select value={filepath} onValueChange={select}>
|
||||
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
|
||||
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none !bg-transparent px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
|
||||
<SelectValue placeholder="Select a file" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="select-none">
|
||||
@@ -1610,25 +1628,19 @@ export function ArtifactFileDetail({
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成独立改写版文件,原文件保持不变;点击停止"
|
||||
: hasLocalEdits
|
||||
? "将先保存当前 Markdown 修改,再开始改写"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
}
|
||||
// Keep the entry available after a completed rewrite and when
|
||||
// the user has local edits. Starting with local edits saves
|
||||
// them automatically in handleStartDocumentRewrite.
|
||||
onClick={() => {
|
||||
if (documentRewriteActive) {
|
||||
handleCancelDocumentRewrite();
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||
: hasLocalEdits
|
||||
? "将先保存当前 Markdown 修改,再开始改写"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
}
|
||||
// Keep the entry available after a completed rewrite and when
|
||||
// the user has local edits. Starting with local edits saves
|
||||
// them automatically in handleStartDocumentRewrite.
|
||||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
@@ -1754,6 +1766,27 @@ export function ArtifactFileDetail({
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{documentRewriteActive && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
{documentRewrite.run.stage === "requirements"
|
||||
? "正在理解改写需求,原文件未被修改"
|
||||
: documentRewrite.run.stage === "validation"
|
||||
? "正在校验 Markdown"
|
||||
: documentRewrite.run.stage === "committing"
|
||||
? "正在安全替换原文件"
|
||||
: "正在写入临时草稿 · " +
|
||||
documentRewrite.run.writtenCharCount.toLocaleString() +
|
||||
" 字"}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
|
||||
onClick={handleCancelDocumentRewrite}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1764,7 +1797,7 @@ export function ArtifactFileDetail({
|
||||
滑动到底部并继续自动下滑
|
||||
</button>
|
||||
)}
|
||||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||
<MarkdownAiEditor
|
||||
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
|
||||
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
|
||||
@@ -1821,16 +1854,16 @@ export function ArtifactFileDetail({
|
||||
? (displayContent ?? "")
|
||||
: sandboxDisplayText
|
||||
}
|
||||
readonly={!canEditArtifact || documentRewriteActive}
|
||||
onChange={
|
||||
canEditArtifact && !documentRewriteActive
|
||||
readonly={!canEditArtifact || documentRewriteActive}
|
||||
onChange={
|
||||
canEditArtifact && !documentRewriteActive
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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) &&
|
||||
@@ -822,13 +815,13 @@ const ChatBox: React.FC<{
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<div className="flex size-full max-w-[var(--container-width-sm)] flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
className="max-w-[var(--container-width-sm)] p-4 pt-12"
|
||||
files={artifacts}
|
||||
threadId={threadId}
|
||||
/>
|
||||
|
||||
@@ -83,8 +83,8 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
{isStreaming && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
"resize-none p-4! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:!outline-none",
|
||||
"resize-none !p-4 [&_.cm-line]:!px-2 [&_.cm-line]:!py-0",
|
||||
"border-none",
|
||||
)}
|
||||
readOnly
|
||||
@@ -95,8 +95,8 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
readOnly={readonly ?? disabled}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
"px-2 py-0! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:!outline-none",
|
||||
"px-2 !py-0 [&_.cm-line]:!px-2 [&_.cm-line]:!py-0",
|
||||
)}
|
||||
theme={resolvedTheme === "dark" ? customDarkTheme : customLightTheme}
|
||||
extensions={extensions}
|
||||
|
||||
@@ -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,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
@@ -63,21 +61,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { apiFetch } from "@/core/api";
|
||||
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
resolveModelReasoningEffort,
|
||||
type StoredReasoningEffort,
|
||||
} from "@/core/models/reasoning-effort";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -135,7 +124,6 @@ export function InputBox({
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeRetrieval,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
@@ -158,7 +146,7 @@ export function InputBox({
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: StoredReasoningEffort;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
@@ -174,7 +162,7 @@ export function InputBox({
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: StoredReasoningEffort;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
},
|
||||
) => void;
|
||||
@@ -209,12 +197,6 @@ export function InputBox({
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
|
||||
knowledgeRetrieval?: {
|
||||
mode: KnowledgeRetrievalMode;
|
||||
smartAvailable: boolean;
|
||||
onModeChange: (mode: KnowledgeRetrievalMode) => void;
|
||||
};
|
||||
/** High-value starter questions derived from the currently selected spaces. */
|
||||
knowledgeSuggestedQuestions?: string[];
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
@@ -234,25 +216,11 @@ export function InputBox({
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const rewritePresentation = useDocumentRewritePresentation();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const documentRewriteActive = useMemo(
|
||||
() =>
|
||||
rewritePresentation?.forThread(threadId).some((item) =>
|
||||
isDocumentRewriteActiveStage(item.stage),
|
||||
) ?? false,
|
||||
[rewritePresentation, threadId],
|
||||
);
|
||||
// Keep normal chat generation authoritative when both states overlap. In
|
||||
// the usual rewrite-only case this gives the composer the same square stop
|
||||
// affordance as a normal streamed reply.
|
||||
const composerStatus: ChatStatus = documentRewriteActive
|
||||
? "streaming"
|
||||
: status;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -267,7 +235,6 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const followupsEnabled = userPreferences.followup_suggestions_enabled;
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
@@ -286,21 +253,12 @@ export function InputBox({
|
||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const supportsReasoningEffort =
|
||||
fallbackModel.supports_reasoning_effort ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||
const nextReasoningEffort = resolveModelReasoningEffort(
|
||||
supportsReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
context.model_name !== nextModelName,
|
||||
fallbackModel.default_reasoning_effort,
|
||||
);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
context.mode === nextMode &&
|
||||
context.reasoning_effort === nextReasoningEffort &&
|
||||
context.execution_profile === fallbackModel.execution_profile
|
||||
) {
|
||||
return;
|
||||
@@ -310,7 +268,6 @@ export function InputBox({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
reasoning_effort: nextReasoningEffort,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||
@@ -335,16 +292,6 @@ export function InputBox({
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const selectedReasoningEffort = useMemo(
|
||||
() =>
|
||||
resolveModelReasoningEffort(
|
||||
supportReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
false,
|
||||
selectedModel?.default_reasoning_effort,
|
||||
),
|
||||
[context.reasoning_effort, selectedModel?.default_reasoning_effort, supportReasoningEffort],
|
||||
);
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
const model = modelOptions.find((m) => m.name === model_name);
|
||||
@@ -355,12 +302,7 @@ export function InputBox({
|
||||
...context,
|
||||
model_name,
|
||||
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,
|
||||
),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
@@ -379,22 +321,18 @@ export function InputBox({
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
reasoning_effort: resolveModelReasoningEffort(
|
||||
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
false,
|
||||
ordinaryModel?.default_reasoning_effort,
|
||||
),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
: mode === "pro"
|
||||
? "medium"
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal",
|
||||
execution_profile: undefined,
|
||||
});
|
||||
},
|
||||
[
|
||||
onContextChange,
|
||||
context,
|
||||
models,
|
||||
supportThinking,
|
||||
supportReasoningEffort,
|
||||
],
|
||||
[onContextChange, context, models, supportThinking],
|
||||
);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
@@ -423,6 +361,16 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
reasoning_effort: effort,
|
||||
});
|
||||
},
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
@@ -438,13 +386,8 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const smartKnowledgeRetrievalActive =
|
||||
knowledgeRetrieval?.mode === "smart_reasoning";
|
||||
const ragModeLocked =
|
||||
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
!smartKnowledgeRetrievalActive &&
|
||||
(localSettings.display.ragModeEnabled || ragModeLocked);
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
@@ -460,16 +403,12 @@ 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.");
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!hasPromptInputPayload(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -482,11 +421,7 @@ export function InputBox({
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (
|
||||
resolvedModelName &&
|
||||
(context.model_name !== resolvedModelName ||
|
||||
context.reasoning_effort !== selectedReasoningEffort)
|
||||
) {
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
@@ -494,7 +429,6 @@ export function InputBox({
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
reasoning_effort: selectedReasoningEffort,
|
||||
execution_profile: selectedModel?.execution_profile,
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
@@ -505,54 +439,26 @@ export function InputBox({
|
||||
},
|
||||
[
|
||||
context,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
composerStatus,
|
||||
resolvedModelName,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
selectedReasoningEffort,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleStopCurrentGeneration = useCallback(() => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!documentRewriteActive || !rewritePresentation) return;
|
||||
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
|
||||
if (!result.cancelled) {
|
||||
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
|
||||
}
|
||||
});
|
||||
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
const form = promptRootRef.current?.querySelector("form");
|
||||
form?.requestSubmit();
|
||||
}, []);
|
||||
|
||||
const 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 (status === "streaming") {
|
||||
return;
|
||||
}
|
||||
const current = (textInput.value ?? "").trim();
|
||||
@@ -565,7 +471,7 @@ export function InputBox({
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
},
|
||||
[composerStatus, requestFormSubmit, textInput],
|
||||
[requestFormSubmit, status, textInput],
|
||||
);
|
||||
|
||||
const confirmReplaceAndSend = useCallback(() => {
|
||||
@@ -597,7 +503,6 @@ export function InputBox({
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const followupsPresent =
|
||||
followupsEnabled &&
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
@@ -619,20 +524,11 @@ export function InputBox({
|
||||
return () => followupsPresenceChangeRef.current?.(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (followupsEnabled) return;
|
||||
setFollowups([]);
|
||||
setFollowupsLoading(false);
|
||||
setFollowupsHidden(true);
|
||||
setConfirmOpen(false);
|
||||
setPendingSuggestion(null);
|
||||
}, [followupsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const streaming = status === "streaming";
|
||||
const wasStreaming = wasStreamingRef.current;
|
||||
wasStreamingRef.current = streaming;
|
||||
if (!followupsEnabled || !wasStreaming || streaming) {
|
||||
if (!wasStreaming || streaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -684,7 +580,6 @@ export function InputBox({
|
||||
return (await res.json()) as { suggestions?: string[] };
|
||||
})
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const suggestions = (data.suggestions ?? [])
|
||||
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
||||
.filter((s) => s.length > 0)
|
||||
@@ -692,19 +587,14 @@ export function InputBox({
|
||||
setFollowups(suggestions);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFollowups([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFollowupsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
setFollowupsLoading(false);
|
||||
};
|
||||
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
return () => controller.abort();
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
@@ -724,7 +614,9 @@ export function InputBox({
|
||||
type="button"
|
||||
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
fillPromptForEditing(question);
|
||||
textInput.setInput(question);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
@@ -742,7 +634,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)}
|
||||
@@ -832,8 +726,8 @@ export function InputBox({
|
||||
<PromptInputTextarea
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||
isNewThread && "!min-h-[110px]",
|
||||
compact && "!min-h-[84px] px-3 pt-3 pb-1 text-[14px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -852,7 +746,7 @@ export function InputBox({
|
||||
>
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className="px-2!" />
|
||||
<PromptInputActionMenuTrigger className="!px-2" />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments
|
||||
label={t.inputBox.addAttachments}
|
||||
@@ -862,9 +756,9 @@ export function InputBox({
|
||||
{!frontendRagActive && (
|
||||
<AddAttachmentsButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
"rounded-full border-0 bg-transparent !px-2.5 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -877,8 +771,8 @@ export function InputBox({
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
context.writing_mode
|
||||
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
|
||||
: compact
|
||||
@@ -906,8 +800,8 @@ export function InputBox({
|
||||
aria-pressed={artifactDirectoryModeEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
artifactDirectoryModeEnabled
|
||||
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
|
||||
: compact
|
||||
@@ -923,42 +817,36 @@ 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 && !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
|
||||
@@ -971,8 +859,8 @@ export function InputBox({
|
||||
aria-pressed={contextCompressionEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
contextCompressionEnabled
|
||||
? "border border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: compact
|
||||
@@ -1008,8 +896,8 @@ export function InputBox({
|
||||
>
|
||||
<PromptInputActionMenuTrigger
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
"!gap-1.5 rounded-full border-0 bg-transparent !px-3 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border !px-2.5 !py-1 text-[11px] font-medium",
|
||||
compact &&
|
||||
(frontendRagActive
|
||||
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
@@ -1251,6 +1139,122 @@ export function InputBox({
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className={cn("!gap-1.5 rounded-full border-0 bg-transparent !px-3 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border !px-2.5 !py-1 text-[13px]")}>
|
||||
<div className="text-xs font-normal">
|
||||
{t.inputBox.reasoningEffort}:
|
||||
{context.reasoning_effort === "minimal" &&
|
||||
" " + t.inputBox.reasoningEffortMinimal}
|
||||
{context.reasoning_effort === "low" &&
|
||||
" " + t.inputBox.reasoningEffortLow}
|
||||
{context.reasoning_effort === "medium" &&
|
||||
" " + t.inputBox.reasoningEffortMedium}
|
||||
{context.reasoning_effort === "high" &&
|
||||
" " + t.inputBox.reasoningEffortHigh}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent className="w-70">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
{t.inputBox.reasoningEffort}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "minimal"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("minimal")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortMinimal}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortMinimalDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "minimal" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "low"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("low")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortLow}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortLowDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "low" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "medium" ||
|
||||
!context.reasoning_effort
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("medium")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortMedium}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortMediumDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "medium" ||
|
||||
!context.reasoning_effort ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "high"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("high")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortHigh}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortHighDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "high" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</PromptInputActionMenu>
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
@@ -1262,83 +1266,6 @@ export function InputBox({
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && knowledgeSelector}
|
||||
{!frontendRagActive && knowledgeRetrieval && (
|
||||
<DropdownMenu>
|
||||
<Tooltip
|
||||
content={
|
||||
knowledgeRetrieval.mode === "smart_reasoning"
|
||||
? "智能检索:综合原文与 Wiki 线索,提供更全面的背景和解释"
|
||||
: "经典检索:细粒度召回原文,优先回答具体事实与细节"
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`普通知识库检索模式:${
|
||||
knowledgeRetrieval.mode === "smart_reasoning"
|
||||
? "智能检索"
|
||||
: "经典检索"
|
||||
}`}
|
||||
aria-haspopup="menu"
|
||||
className={cn(
|
||||
"rounded-full px-3 shadow-none transition-colors",
|
||||
knowledgeRetrieval.mode === "smart_reasoning"
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<BrainCircuitIcon className="size-3.5" />
|
||||
<span className="max-w-[160px] truncate text-xs font-normal">
|
||||
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
|
||||
</span>
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-80 rounded-xl p-1.5"
|
||||
>
|
||||
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
|
||||
普通知识库检索模式
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
className="min-h-16 items-start rounded-lg py-2"
|
||||
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
|
||||
>
|
||||
<BookMarkedIcon className="mt-0.5 size-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">经典检索(默认)</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
细粒度召回知识库原文,优先定位与问题最相关的具体段落,适合查询事实、条款、数字和原文细节
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeRetrieval.mode === "classic" && (
|
||||
<CheckIcon className="mt-0.5 size-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-h-16 items-start rounded-lg py-2"
|
||||
disabled={!knowledgeRetrieval.smartAvailable}
|
||||
onSelect={() =>
|
||||
knowledgeRetrieval.onModeChange("smart_reasoning")
|
||||
}
|
||||
>
|
||||
<BrainCircuitIcon className="mt-0.5 size-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">智能检索</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
综合原文分片与 Wiki 目录、页面和关联线索扩展检索,适合全面梳理背景、关联关系和综合解释
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeRetrieval.mode === "smart_reasoning" && (
|
||||
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{!frontendRagActive && promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -1372,11 +1299,12 @@ export function InputBox({
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
<PromptInputButton
|
||||
data-firefox52-smoke="model-selector"
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
"rounded-lg border border-transparent bg-gray-50/60 !px-2.5 !py-1 font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col items-start text-left">
|
||||
@@ -1423,21 +1351,11 @@ 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}
|
||||
variant="outline"
|
||||
status={status}
|
||||
/>
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
{!isNewThread && (
|
||||
@@ -1547,7 +1465,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Tooltip content={t.inputBox.addAttachments}>
|
||||
<PromptInputButton
|
||||
className={cn("px-2!", className)}
|
||||
className={cn("!px-2", className)}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
<PaperclipIcon className="size-3" />
|
||||
|
||||
@@ -20,14 +20,8 @@ import {
|
||||
|
||||
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 function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
@@ -37,8 +31,7 @@ export function KnowledgeProductNav() {
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
// const assistantVisibility = useAssistantKnowledgeVisibility();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const landing = useOrdinaryKnowledgeLanding(isAdmin);
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
@@ -46,6 +39,7 @@ export function KnowledgeProductNav() {
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
// const assistantAllowed = canAccessAssistantKnowledge(
|
||||
// isAdmin,
|
||||
// assistantVisibility.isError ? undefined : assistantVisibility.data,
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
|
||||
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
|
||||
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
@@ -48,14 +46,14 @@ export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||
const name = cleanNames[0]!;
|
||||
return [
|
||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
|
||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
|
||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||
];
|
||||
@@ -428,13 +426,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const assistantAccess = useAssistantKnowledgeAccess({
|
||||
enabled:
|
||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
|
||||
enabled: onAssistantChange !== undefined,
|
||||
});
|
||||
const assistantAllowed =
|
||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
|
||||
onAssistantChange !== undefined &&
|
||||
assistantAccess.canAccess;
|
||||
onAssistantChange !== undefined && assistantAccess.canAccess;
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const shouldLoadOptions =
|
||||
@@ -494,12 +489,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
[selectedAssistantNames, selectedNames],
|
||||
);
|
||||
const selectedConfiguredQuestions = useMemo(
|
||||
() =>
|
||||
userFacingKnowledgeSuggestedQuestions(
|
||||
value.flatMap(
|
||||
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
|
||||
),
|
||||
),
|
||||
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
|
||||
[rows, value],
|
||||
);
|
||||
const suggestedQuestions = useMemo(
|
||||
@@ -559,11 +549,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
value={value}
|
||||
assistantValue={effectiveAssistantValue}
|
||||
onChange={onChange}
|
||||
onAssistantChange={
|
||||
assistantAllowed
|
||||
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
|
||||
: IGNORE_ASSISTANT_CHANGE
|
||||
}
|
||||
onAssistantChange={onAssistantChange ?? IGNORE_ASSISTANT_CHANGE}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
CopyIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
@@ -15,7 +14,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -24,7 +22,6 @@ import {
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
@@ -45,7 +42,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
const REWRITE_STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "生成改写版 Markdown" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
@@ -224,9 +221,6 @@ export function DocumentRewriteTimeline({
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const diagnosticText = item.diagnostic
|
||||
? JSON.stringify(item.diagnostic, null, 2)
|
||||
: "";
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
@@ -376,7 +370,7 @@ export function DocumentRewriteTimeline({
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
@@ -446,13 +440,13 @@ export function DocumentRewriteTimeline({
|
||||
{step.key === "writing" && (
|
||||
item.writtenCharCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
|
||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{isNewReportGeneration
|
||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
|
||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
@@ -589,42 +583,9 @@ export function DocumentRewriteTimeline({
|
||||
)}
|
||||
|
||||
{item.error && (
|
||||
<div className="border-t px-5 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{item.error}</span>
|
||||
</div>
|
||||
{item.diagnostic && (
|
||||
<Collapsible className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
查看后台诊断
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
className="h-8 gap-1.5 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(diagnosticText)
|
||||
.then(() => toast.success("诊断信息已复制"))
|
||||
.catch(() => toast.error("复制诊断信息失败"));
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
复制诊断
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{diagnosticText}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
<DocumentRewriteDiffDialog
|
||||
@@ -637,36 +598,3 @@ export function DocumentRewriteTimeline({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** A standalone chat row for a sandbox document-rewrite task. */
|
||||
export function DocumentRewriteMessage({
|
||||
item,
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
item: DocumentRewritePresentation;
|
||||
threadId: string;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
}) {
|
||||
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
|
||||
const completed = item.stage === "completed";
|
||||
|
||||
return (
|
||||
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
|
||||
<DocumentRewriteTimeline item={item} />
|
||||
{completed && artifactPath && (
|
||||
<ArtifactFileList
|
||||
fileDescriptions={{
|
||||
[artifactPath]: "AI 改写版 Markdown 文件",
|
||||
}}
|
||||
files={[artifactPath]}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -119,11 +119,7 @@ export function MessageGroup({
|
||||
() => convertToSteps(messages, writingModeEnabled),
|
||||
[messages, writingModeEnabled],
|
||||
);
|
||||
const { skills } = useSkills({
|
||||
enabled:
|
||||
messages.length > 0 &&
|
||||
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
|
||||
});
|
||||
const { skills } = useSkills();
|
||||
const retrievedFiles = useMemo(
|
||||
() => citationDisplay?.retrieved_files_enabled === true
|
||||
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
|
||||
@@ -227,7 +223,6 @@ export function MessageGroup({
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
@@ -240,7 +235,6 @@ export function MessageGroup({
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
@@ -356,7 +350,6 @@ function ToolCall({
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -367,7 +360,6 @@ function ToolCall({
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -378,42 +370,19 @@ 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,
|
||||
)
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
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 wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: name === "llmwiki_read_wiki_page"
|
||||
? wikiQuery
|
||||
? `读取 Wiki 页面「${wikiQuery}」`
|
||||
: "读取 Wiki 页面"
|
||||
: name === "llmwiki_browse_wiki_index"
|
||||
? "读取 Wiki 索引与知识库摘要"
|
||||
: null;
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -439,17 +408,12 @@ function ToolCall({
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = syncDisplaySourceIndexes(
|
||||
rankDisplaySources(
|
||||
normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
})),
|
||||
),
|
||||
displaySources,
|
||||
);
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
@@ -884,8 +848,8 @@ function ArtifactWriteToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function InlineWritePreview({ content }: { content: string }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const follow = useRef(true);
|
||||
@@ -928,10 +892,6 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
|
||||
// per-name cursor so parallel searches consume distinct unbound results in
|
||||
// order instead of every step reusing the first page.
|
||||
const toolCallNameFallbackIndexes = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
if (message.type === "ai") {
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
@@ -996,23 +956,11 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
let toolCallResult = findToolCallResult(
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
{ allowNameFallback: false },
|
||||
);
|
||||
if (toolCallResult === undefined) {
|
||||
const nameKey = tool_call.name.toLowerCase();
|
||||
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
|
||||
toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
|
||||
);
|
||||
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
|
||||
}
|
||||
if (toolCallResult !== undefined) {
|
||||
if (typeof toolCallResult === "string") {
|
||||
try {
|
||||
@@ -1079,48 +1027,6 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function rankDisplaySources(
|
||||
sources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
return sources
|
||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore =
|
||||
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||
? left.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore =
|
||||
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||
? right.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ source }, index) => ({ ...source, index: index + 1 }));
|
||||
}
|
||||
|
||||
function syncDisplaySourceIndexes(
|
||||
sources: RuntimeDisplaySource[],
|
||||
globalSources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
const indexByCitationId = new Map(
|
||||
globalSources.flatMap((source) => {
|
||||
const citationId =
|
||||
typeof source.raw?.citation_id === "string"
|
||||
? source.raw.citation_id.trim()
|
||||
: "";
|
||||
return citationId ? [[citationId, source.index] as const] : [];
|
||||
}),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
const citationId =
|
||||
typeof source.raw?.citation_id === "string"
|
||||
? source.raw.citation_id.trim()
|
||||
: "";
|
||||
const index = citationId ? indexByCitationId.get(citationId) : undefined;
|
||||
return index ? { ...source, index } : source;
|
||||
});
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
|
||||
@@ -17,12 +17,6 @@ import {
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
INITIAL_VISIBLE_HUMAN_TURNS,
|
||||
MORE_VISIBLE_HUMAN_TURNS,
|
||||
REVEAL_CHAT_HISTORY_EVENT,
|
||||
sliceMessagesByRecentHumanTurns,
|
||||
} from "@/core/messages/history-window";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractPresentFilesFromMessage,
|
||||
@@ -38,7 +32,6 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
@@ -47,35 +40,22 @@ import {
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import {
|
||||
shouldRenderUnattachedKnowledgeRetrievalProgress,
|
||||
useKnowledgeRetrievalProgress,
|
||||
} from "@/core/threads/knowledge-retrieval-progress";
|
||||
import {
|
||||
buildSmartKnowledgeRetrievalDisplaySegments,
|
||||
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
|
||||
hasSmartKnowledgeRetrievalCalls,
|
||||
latestSmartKnowledgeRetrievalMessageId,
|
||||
type SmartKnowledgeRetrievalResultItem,
|
||||
} from "@/core/threads/smart-knowledge-retrieval";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { getQuestionAnchorId } from "../question-navigator";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
||||
import {
|
||||
MessageMetricsProvider,
|
||||
MessageTokenUsageList,
|
||||
@@ -87,7 +67,7 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
||||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
||||
function ScrollToBottomOnUserMessage({
|
||||
function ScrollToBottomOnUserMessage({
|
||||
threadId,
|
||||
messages,
|
||||
}: {
|
||||
@@ -131,54 +111,54 @@ function ScrollToBottomOnUserMessage({
|
||||
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
}, [scrollToBottom, threadId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the conversation's scroll position to hosts that render controls
|
||||
* outside the scroll container (for example the docked follow-up questions).
|
||||
*/
|
||||
function ConversationBottomObserver({
|
||||
onAtBottomChange,
|
||||
}: {
|
||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||
}) {
|
||||
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
|
||||
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const update = () => {
|
||||
const distance =
|
||||
scrollElement.scrollHeight -
|
||||
scrollElement.scrollTop -
|
||||
scrollElement.clientHeight;
|
||||
setIsExactlyAtBottom(distance <= 2);
|
||||
};
|
||||
update();
|
||||
scrollElement.addEventListener("scroll", update, { passive: true });
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
||||
resizeObserver?.observe(scrollElement);
|
||||
if (contentRef.current) resizeObserver?.observe(contentRef.current);
|
||||
return () => {
|
||||
scrollElement.removeEventListener("scroll", update);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [contentRef, scrollRef]);
|
||||
|
||||
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
|
||||
|
||||
useEffect(() => {
|
||||
onAtBottomChange?.(displayControlsAtBottom);
|
||||
}, [displayControlsAtBottom, onAtBottomChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the conversation's scroll position to hosts that render controls
|
||||
* outside the scroll container (for example the docked follow-up questions).
|
||||
*/
|
||||
function ConversationBottomObserver({
|
||||
onAtBottomChange,
|
||||
}: {
|
||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||
}) {
|
||||
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
|
||||
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const update = () => {
|
||||
const distance =
|
||||
scrollElement.scrollHeight -
|
||||
scrollElement.scrollTop -
|
||||
scrollElement.clientHeight;
|
||||
setIsExactlyAtBottom(distance <= 2);
|
||||
};
|
||||
update();
|
||||
scrollElement.addEventListener("scroll", update, { passive: true });
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
||||
resizeObserver?.observe(scrollElement);
|
||||
if (contentRef.current) resizeObserver?.observe(contentRef.current);
|
||||
return () => {
|
||||
scrollElement.removeEventListener("scroll", update);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [contentRef, scrollRef]);
|
||||
|
||||
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
|
||||
|
||||
useEffect(() => {
|
||||
onAtBottomChange?.(displayControlsAtBottom);
|
||||
}, [displayControlsAtBottom, onAtBottomChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
fitMessageRowsToContent = false,
|
||||
@@ -186,10 +166,10 @@ export function MessageList({
|
||||
thread,
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
scrollButtonClassName,
|
||||
onAtBottomChange,
|
||||
onClarificationSubmit,
|
||||
extraRehypePlugins,
|
||||
scrollButtonClassName,
|
||||
onAtBottomChange,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
messageContentOverrides,
|
||||
@@ -206,10 +186,10 @@ export function MessageList({
|
||||
businessCardSlot,
|
||||
speakerShell,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
answerFeedbackEnabled = false,
|
||||
}: {
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
answerFeedbackEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -217,10 +197,10 @@ export function MessageList({
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
tokenUsageEnabled?: boolean;
|
||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||
scrollButtonClassName?: string;
|
||||
/** Reports whether the message viewport is currently at its bottom edge. */
|
||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||
scrollButtonClassName?: string;
|
||||
/** Reports whether the message viewport is currently at its bottom edge. */
|
||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
@@ -264,15 +244,15 @@ export function MessageList({
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||
afterDocumentRewriteItems?: React.ReactNode;
|
||||
/**
|
||||
* Enables per-answer user feedback for persisted assistant replies. Kept
|
||||
* opt-in because MessageList is also reused by read-only previews, setup
|
||||
* flows and virtual business-card feeds.
|
||||
*/
|
||||
answerFeedbackEnabled?: boolean;
|
||||
}) {
|
||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||
afterDocumentRewriteItems?: React.ReactNode;
|
||||
/**
|
||||
* Enables per-answer user feedback for persisted assistant replies. Kept
|
||||
* opt-in because MessageList is also reused by read-only previews, setup
|
||||
* flows and virtual business-card feeds.
|
||||
*/
|
||||
answerFeedbackEnabled?: boolean;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
@@ -293,25 +273,9 @@ export function MessageList({
|
||||
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
|
||||
item: SmartKnowledgeRetrievalResultItem;
|
||||
target: WeKnoraSourceTarget;
|
||||
} | null>(null);
|
||||
const [visibleHumanTurns, setVisibleHumanTurns] = useState(
|
||||
INITIAL_VISIBLE_HUMAN_TURNS,
|
||||
);
|
||||
useEffect(() => {
|
||||
setHiddenMessageIds(new Set());
|
||||
setSelectedRetrievalResult(null);
|
||||
setVisibleHumanTurns(INITIAL_VISIBLE_HUMAN_TURNS);
|
||||
}, [threadId]);
|
||||
useEffect(() => {
|
||||
const reveal = () => {
|
||||
setVisibleHumanTurns(Number.MAX_SAFE_INTEGER);
|
||||
};
|
||||
window.addEventListener(REVEAL_CHAT_HISTORY_EVENT, reveal);
|
||||
return () => window.removeEventListener(REVEAL_CHAT_HISTORY_EVENT, reveal);
|
||||
}, []);
|
||||
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||||
if (deletedIds.length === 0) return;
|
||||
setHiddenMessageIds((prev) => {
|
||||
@@ -321,20 +285,9 @@ export function MessageList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const documentRewriteItems = useMemo(
|
||||
() => documentRewritePresentation?.forThread(threadId) ?? [],
|
||||
[documentRewritePresentation, threadId],
|
||||
);
|
||||
const artifactRewriteItems = useMemo(
|
||||
() => documentRewriteItems.filter(
|
||||
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
|
||||
),
|
||||
[documentRewriteItems],
|
||||
);
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||
const persistedMessages = thread.messages
|
||||
return thread.messages
|
||||
.filter((m) => !isHiddenFromUIMessage(m))
|
||||
.map((message) => {
|
||||
const override =
|
||||
@@ -345,24 +298,8 @@ export function MessageList({
|
||||
? message
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
|
||||
{
|
||||
id: `document-rewrite-input-${item.id}`,
|
||||
type: "human",
|
||||
content: "AI改写",
|
||||
} as unknown as Message,
|
||||
{
|
||||
id: `document-rewrite-${item.id}`,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: {
|
||||
document_rewrite_presentation_id: item.id,
|
||||
},
|
||||
} as unknown as Message,
|
||||
]);
|
||||
return [...persistedMessages, ...rewriteMessages];
|
||||
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
@@ -374,61 +311,42 @@ export function MessageList({
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const { messages: displayMessages, hiddenHumanTurns } = useMemo(
|
||||
() => sliceMessagesByRecentHumanTurns(messages, visibleHumanTurns),
|
||||
[messages, visibleHumanTurns],
|
||||
);
|
||||
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
|
||||
const hasHumanMessage = messages.some((message) => message.type === "human");
|
||||
const currentTurnMessages = useMemo(() => {
|
||||
let lastHumanIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index]?.type === "human") {
|
||||
lastHumanIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return messages.slice(lastHumanIndex + 1);
|
||||
}, [messages]);
|
||||
const latestCurrentSmartMessageId = useMemo(
|
||||
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
|
||||
[currentTurnMessages],
|
||||
);
|
||||
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
|
||||
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
|
||||
const answerFeedbackMessageIds = useMemo(() => {
|
||||
if (!feedbackAvailable) return [];
|
||||
const streamingAssistantId = thread.isLoading
|
||||
? [...messages].reverse().find((message) => message.type === "ai")?.id
|
||||
: undefined;
|
||||
return messages.flatMap((message) => {
|
||||
const kwargs = message.additional_kwargs ?? {};
|
||||
const explicitlyStreaming =
|
||||
kwargs.deep_research_summary_streaming === true ||
|
||||
kwargs.deep_research_follow_up_streaming === true;
|
||||
const loading =
|
||||
explicitlyStreaming ||
|
||||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
|
||||
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
|
||||
? [message.id]
|
||||
: [];
|
||||
});
|
||||
}, [feedbackAvailable, messages, thread.isLoading]);
|
||||
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
|
||||
threadId,
|
||||
answerFeedbackMessageIds,
|
||||
feedbackAvailable,
|
||||
);
|
||||
const ratableAnswerMessageIds = useMemo(() => {
|
||||
const lastMessageByRun = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!message.id) continue;
|
||||
const context = answerFeedbackContexts?.[message.id];
|
||||
if (context) lastMessageByRun.set(context.run_id, message.id);
|
||||
}
|
||||
return new Set(lastMessageByRun.values());
|
||||
}, [answerFeedbackContexts, messages]);
|
||||
);
|
||||
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
|
||||
const answerFeedbackMessageIds = useMemo(() => {
|
||||
if (!feedbackAvailable) return [];
|
||||
const streamingAssistantId = thread.isLoading
|
||||
? [...messages].reverse().find((message) => message.type === "ai")?.id
|
||||
: undefined;
|
||||
return messages.flatMap((message) => {
|
||||
const kwargs = message.additional_kwargs ?? {};
|
||||
const explicitlyStreaming =
|
||||
kwargs.deep_research_summary_streaming === true ||
|
||||
kwargs.deep_research_follow_up_streaming === true;
|
||||
const loading =
|
||||
explicitlyStreaming ||
|
||||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
|
||||
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
|
||||
? [message.id]
|
||||
: [];
|
||||
});
|
||||
}, [feedbackAvailable, messages, thread.isLoading]);
|
||||
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
|
||||
threadId,
|
||||
answerFeedbackMessageIds,
|
||||
feedbackAvailable,
|
||||
);
|
||||
const ratableAnswerMessageIds = useMemo(() => {
|
||||
const lastMessageByRun = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!message.id) continue;
|
||||
const context = answerFeedbackContexts?.[message.id];
|
||||
if (context) lastMessageByRun.set(context.run_id, message.id);
|
||||
}
|
||||
return new Set(lastMessageByRun.values());
|
||||
}, [answerFeedbackContexts, messages]);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
||||
@@ -491,9 +409,9 @@ export function MessageList({
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true ||
|
||||
!!threadContext?.knowledgeReferences;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true ||
|
||||
!!threadContext?.knowledgeReferences;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
@@ -503,11 +421,7 @@ export function MessageList({
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills({
|
||||
enabled:
|
||||
messages.length > 0 &&
|
||||
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
|
||||
});
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
@@ -559,29 +473,10 @@ export function MessageList({
|
||||
return map;
|
||||
}, [thread.messages]);
|
||||
|
||||
const smartRetrievalAssistantMessageIds = useMemo(() => {
|
||||
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
|
||||
}, [messages]);
|
||||
|
||||
const resolveDisplaySources = useCallback(
|
||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||
if (isMultiRoundRagMessage(msg)) return [];
|
||||
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
|
||||
// Smart retrieval has its own clickable evidence trace and explicitly
|
||||
// does not require numbered references. Do not feed the same transport
|
||||
// results into the legacy citation layer, which would append [1][2] to
|
||||
// an otherwise complete answer.
|
||||
const isSmartRetrievalTurn = msg.id
|
||||
? smartRetrievalAssistantMessageIds.has(msg.id)
|
||||
: hasSmartKnowledgeRetrievalCalls(
|
||||
messages.slice(
|
||||
previousHumanId
|
||||
? messages.findIndex((message) => message.id === previousHumanId) + 1
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
if (isSmartRetrievalTurn) return [];
|
||||
const batchSources =
|
||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||
?.sources ?? [];
|
||||
@@ -591,7 +486,7 @@ export function MessageList({
|
||||
if (batchSources.length > 0) return batchSources;
|
||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: previousHumanId,
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
});
|
||||
@@ -602,7 +497,6 @@ export function MessageList({
|
||||
referenceBatches,
|
||||
referencesEnabled,
|
||||
skillDisplayMap,
|
||||
smartRetrievalAssistantMessageIds,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -718,37 +612,26 @@ export function MessageList({
|
||||
return (
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
|
||||
<ConversationContent
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
"mx-auto w-full max-w-[var(--container-width-lg)]",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{hiddenHumanTurns > 0 ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-8 rounded-full px-3 text-xs"
|
||||
onClick={() =>
|
||||
setVisibleHumanTurns(
|
||||
(current) => current + MORE_VISIBLE_HUMAN_TURNS,
|
||||
)
|
||||
}
|
||||
>
|
||||
查看更早的对话(还有 {hiddenHumanTurns} 轮)
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
let didRenderLiveKnowledgeRetrieval = false;
|
||||
const grouped = groupMessages(displayMessages, (group) => {
|
||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||
(item) => item.operation !== "generate",
|
||||
);
|
||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||
(item) => item.operation === "generate",
|
||||
);
|
||||
let didRenderRewriteAboveFile = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
const items = group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
@@ -792,14 +675,14 @@ export function MessageList({
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
answerFeedbackEnabled={answerFeedbackEnabled}
|
||||
answerFeedbackContext={
|
||||
msg.id && ratableAnswerMessageIds.has(msg.id)
|
||||
? answerFeedbackContexts?.[msg.id]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
answerFeedbackEnabled={answerFeedbackEnabled}
|
||||
answerFeedbackContext={
|
||||
msg.id && ratableAnswerMessageIds.has(msg.id)
|
||||
? answerFeedbackContexts?.[msg.id]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (speakerShell && group.type === "assistant") {
|
||||
@@ -933,21 +816,6 @@ export function MessageList({
|
||||
{businessCardSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:document-rewrite") {
|
||||
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
|
||||
const item = typeof presentationId === "string"
|
||||
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
|
||||
: undefined;
|
||||
return item ? (
|
||||
<div className="w-full" key={group.id}>
|
||||
<DocumentRewriteMessage
|
||||
item={item}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -956,8 +824,15 @@ export function MessageList({
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
const rewriteAbove = !didRenderRewriteAboveFile
|
||||
? rewriteItemsAboveFile
|
||||
: [];
|
||||
didRenderRewriteAboveFile = true;
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{rewriteAbove.map((item) => (
|
||||
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
|
||||
))}
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
@@ -1037,7 +912,7 @@ export function MessageList({
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
threadId={threadId}
|
||||
threadId={threadId}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
@@ -1088,95 +963,15 @@ export function MessageList({
|
||||
const processingAi = [...group.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
|
||||
group.messages,
|
||||
);
|
||||
const isCurrentTurnRetrievalGroup = group.messages.some(
|
||||
(message) =>
|
||||
message.id ===
|
||||
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
|
||||
);
|
||||
const receivesLiveRetrievalProgress =
|
||||
knowledgeRetrievalProgress.length > 0 &&
|
||||
isCurrentTurnRetrievalGroup;
|
||||
const retrievalDisplaySegments =
|
||||
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
|
||||
? buildSmartKnowledgeRetrievalDisplaySegments(
|
||||
group.messages,
|
||||
receivesLiveRetrievalProgress
|
||||
? knowledgeRetrievalProgress
|
||||
: [],
|
||||
)
|
||||
: [
|
||||
{
|
||||
kind: "processing" as const,
|
||||
id: `processing-${group.id ?? "group"}`,
|
||||
messages: group.messages,
|
||||
},
|
||||
];
|
||||
if (
|
||||
retrievalDisplaySegments.some(
|
||||
(segment) =>
|
||||
segment.kind === "retrieval" &&
|
||||
segment.progressFrames.length > 0,
|
||||
)
|
||||
) {
|
||||
didRenderLiveKnowledgeRetrieval = true;
|
||||
}
|
||||
const processingBody = (
|
||||
<div className="w-full space-y-2">
|
||||
{retrievalDisplaySegments.map((segment) => {
|
||||
if (segment.kind === "retrieval") {
|
||||
return (
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
key={segment.id}
|
||||
messages={segment.messages}
|
||||
progressFrames={segment.progressFrames}
|
||||
dedupeScopeMessages={group.messages}
|
||||
dedupeScopeProgressFrames={
|
||||
receivesLiveRetrievalProgress
|
||||
? knowledgeRetrievalProgress
|
||||
: []
|
||||
}
|
||||
roundOffset={segment.roundOffset}
|
||||
// The AI tool-call message can arrive before its custom
|
||||
// `started` frame. Keep the current turn active through
|
||||
// that short gap instead of misclassifying the pending
|
||||
// call as an interrupted historical action.
|
||||
isLoading={
|
||||
isCurrentTurnRetrievalGroup && thread.isLoading
|
||||
}
|
||||
onOpenResult={(item, target) =>
|
||||
setSelectedRetrievalResult({ item, target })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const segmentHasVisibleProcessing = segment.messages.some(
|
||||
(message) =>
|
||||
message.type === "tool" ||
|
||||
(message.type === "ai" &&
|
||||
(hasReasoning(message) ||
|
||||
hasContent(message) ||
|
||||
hasToolCalls(message))),
|
||||
);
|
||||
if (!segmentHasVisibleProcessing) return null;
|
||||
const segmentAi = [...segment.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
return (
|
||||
<MessageGroup
|
||||
key={segment.id}
|
||||
threadId={threadId}
|
||||
messages={segment.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(
|
||||
segmentAi ?? processingAi,
|
||||
)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="w-full">
|
||||
<MessageGroup
|
||||
threadId={threadId}
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -1190,27 +985,12 @@ export function MessageList({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const leftoverRewriteItems = documentRewriteItems.filter(
|
||||
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
|
||||
);
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
: documentRewriteItems;
|
||||
return (
|
||||
<>
|
||||
{grouped}
|
||||
{shouldRenderUnattachedKnowledgeRetrievalProgress({
|
||||
frameCount: knowledgeRetrievalProgress.length,
|
||||
isLoading: thread.isLoading,
|
||||
hasHumanMessage,
|
||||
}) &&
|
||||
!didRenderLiveKnowledgeRetrieval && (
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={knowledgeRetrievalProgress}
|
||||
isLoading={thread.isLoading}
|
||||
onOpenResult={(item, target) =>
|
||||
setSelectedRetrievalResult({ item, target })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{leftoverRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
@@ -1232,16 +1012,6 @@ export function MessageList({
|
||||
aria-label="回到最新"
|
||||
title="回到最新"
|
||||
/>
|
||||
<WeKnoraSourceDrawer
|
||||
open={selectedRetrievalResult !== null}
|
||||
target={selectedRetrievalResult?.target ?? null}
|
||||
title={selectedRetrievalResult?.item.title}
|
||||
quote={selectedRetrievalResult?.item.preview ?? ""}
|
||||
collapseRetrievedExcerptByDefault
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedRetrievalResult(null);
|
||||
}}
|
||||
/>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
@@ -1285,9 +1055,9 @@ export type SetupWritingApprovalPayload = {
|
||||
outline?: string;
|
||||
};
|
||||
|
||||
export type SetupReportStructureApprovalPayload = {
|
||||
kind?: "report_structure" | "gongwen_template";
|
||||
title?: string;
|
||||
export type SetupReportStructureApprovalPayload = {
|
||||
kind?: "report_structure" | "gongwen_template";
|
||||
title?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
structure_mode?: string;
|
||||
@@ -1390,8 +1160,8 @@ function SetupAgentApprovalCard({
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
const skills = approvalPayload.skills ?? [];
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
@@ -1687,7 +1457,7 @@ const STRUCTURE_MODE_LABELS: Record<string, string> = {
|
||||
adaptive: "弹性大纲",
|
||||
};
|
||||
|
||||
function SetupReportStructureApprovalCard({
|
||||
function SetupReportStructureApprovalCard({
|
||||
disabled,
|
||||
message,
|
||||
onApprove,
|
||||
@@ -1705,8 +1475,8 @@ function SetupReportStructureApprovalCard({
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
|
||||
const approvalPayload = payload;
|
||||
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
|
||||
const busy = disabled || submitting !== null;
|
||||
const directions = approvalPayload.retrieval_directions ?? [];
|
||||
const skills = approvalPayload.retrieval_skills ?? [];
|
||||
@@ -1731,8 +1501,8 @@ function SetupReportStructureApprovalCard({
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
|
||||
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
|
||||
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
|
||||
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.title ? (
|
||||
@@ -1774,7 +1544,7 @@ function SetupReportStructureApprovalCard({
|
||||
|
||||
{approvalPayload.content ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
|
||||
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.content}
|
||||
</pre>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function MultiRoundRagMessageList({
|
||||
return (
|
||||
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
|
||||
<ConversationContent
|
||||
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
|
||||
className="mx-auto w-full max-w-[var(--container-width-lg)] gap-8 pt-10"
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const isLastAssistant =
|
||||
|
||||
@@ -24,7 +24,7 @@ function SkeletonBar({
|
||||
export function MessageListSkeleton() {
|
||||
let index = 0;
|
||||
return (
|
||||
<div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
|
||||
<div className="flex w-full max-w-[var(--container-width-lg)] flex-col gap-12 p-8 pt-16">
|
||||
<div
|
||||
role="human-message"
|
||||
className="flex w-[50%] flex-col items-end gap-2 self-end"
|
||||
|
||||
-406
@@ -1,406 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
|
||||
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
||||
|
||||
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
status: "started",
|
||||
queries: ["成都世运会"],
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
status: "completed",
|
||||
result_count: 3,
|
||||
elapsed_ms: 420,
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_search",
|
||||
status: "started",
|
||||
pattern: "火炬|开幕|闭幕",
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_search",
|
||||
status: "completed",
|
||||
result_count: 4,
|
||||
elapsed_ms: 1380,
|
||||
},
|
||||
];
|
||||
|
||||
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={completedFrames}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="展开智能检索过程"/);
|
||||
assert.match(html, /aria-expanded="false"/);
|
||||
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
|
||||
assert.match(html, /累计耗时 1\.8 秒/);
|
||||
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
|
||||
});
|
||||
|
||||
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-pending-wiki",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "wiki-pending",
|
||||
name: "ordinary_wiki_search",
|
||||
args: { pattern: "数字中国|数据要素" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as Message[];
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={messages}
|
||||
progressFrames={[]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /检索中/);
|
||||
assert.match(html, /正在查 Wiki/);
|
||||
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
|
||||
});
|
||||
|
||||
test("labels and normalizes a visible model-authored regex", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-regex",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "wiki-regex",
|
||||
name: "ordinary_wiki_search",
|
||||
args: { pattern: "^# [一-龥]{2,10}篇�" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as Message[]}
|
||||
progressFrames={[]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
|
||||
assert.doesNotMatch(html, /一-龥|�/);
|
||||
});
|
||||
|
||||
test("presents an interrupted historical action as neutral instead of failed", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-interrupted-wiki",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "wiki-interrupted",
|
||||
name: "ordinary_wiki_search",
|
||||
args: { pattern: "数字中国" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as Message[];
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={messages}
|
||||
progressFrames={[]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /检索已结束(部分步骤未完成)/);
|
||||
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
|
||||
});
|
||||
|
||||
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[completedFrames[0]!]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-state="open"/);
|
||||
assert.match(html, /检索中/);
|
||||
assert.match(html, /成都世运会/);
|
||||
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
|
||||
});
|
||||
|
||||
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={completedFrames}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-state="open"/);
|
||||
assert.match(html, /检索完成/);
|
||||
assert.match(html, /aria-label="收起智能检索过程"/);
|
||||
assert.match(html, /检索 Wiki/);
|
||||
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
|
||||
assert.doesNotMatch(html, /检索中/);
|
||||
});
|
||||
|
||||
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[completedFrames[0]!]}
|
||||
isLoading
|
||||
roundOffset={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /检索原始文档/);
|
||||
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
|
||||
});
|
||||
|
||||
test("renders stable retrieval results as right-drawer controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_search",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
result_items: [
|
||||
{
|
||||
kind: "wiki",
|
||||
knowledge_base_id: "kb-news",
|
||||
slug: "news/opening",
|
||||
title: "开幕新闻 Wiki",
|
||||
preview: "页面摘要",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-retrieval-result-detail="true"/);
|
||||
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
|
||||
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
|
||||
});
|
||||
|
||||
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_read_page",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
knowledge_base_count: 1,
|
||||
coverage: [
|
||||
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
|
||||
],
|
||||
elapsed_ms: 1260,
|
||||
result_items: [
|
||||
{
|
||||
kind: "wiki",
|
||||
knowledge_base_id: "kb-digital-china",
|
||||
knowledge_base: "数字中国政策目录智能检索远端验收",
|
||||
slug: "digital-china/timeline",
|
||||
page_ref: "wiki-page:v1:digital-china-timeline",
|
||||
title: "数字中国建设时间线",
|
||||
content_markdown:
|
||||
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /读取结果/);
|
||||
assert.match(html, /数字中国建设时间线/);
|
||||
assert.match(html, /页面正文概览,包含建设目标/);
|
||||
assert.match(html, /右侧查看/);
|
||||
assert.match(html, /耗时 1\.3 秒/);
|
||||
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
|
||||
assert.doesNotMatch(html, /知识库知识库/);
|
||||
assert.match(html, /data-retrieval-result-detail="true"/);
|
||||
});
|
||||
|
||||
test("shows all usable document-read results without a generic partial warning", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "document_chunk_read",
|
||||
status: "completed",
|
||||
result_count: 20,
|
||||
partial: true,
|
||||
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
|
||||
message: "原文上下文读取完成,找到 20 条结果",
|
||||
result_items: Array.from({ length: 20 }, (_, index) => ({
|
||||
kind: "raw",
|
||||
knowledge_base_id: "kb-news",
|
||||
knowledge_base: "国内新闻",
|
||||
knowledge_id: "document-long-news",
|
||||
chunk_id: `chunk-${index + 1}`,
|
||||
title: `长篇新闻第 ${index + 1} 段`,
|
||||
content: `原文上下文第 ${index + 1} 段`,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
||||
20,
|
||||
);
|
||||
assert.match(html, /长篇新闻第 1 段/);
|
||||
assert.match(html, /长篇新闻第 20 段/);
|
||||
assert.doesNotMatch(html, /部分数据源返回警告/);
|
||||
assert.doesNotMatch(html, /部分完成/);
|
||||
});
|
||||
|
||||
test("keeps historical results without stable ids as non-clickable summaries", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
result_items: [
|
||||
{
|
||||
kind: "raw",
|
||||
knowledge_base: "旧知识库",
|
||||
title: "旧检索结果",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-retrieval-result-summary="true"/);
|
||||
assert.match(html, /该历史检索结果缺少稳定详情标识/);
|
||||
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
|
||||
});
|
||||
|
||||
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
|
||||
const firstFrame: KnowledgeRetrievalProgressFrame = {
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
retrieval_id: "raw-first",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
result_items: [
|
||||
{
|
||||
kind: "raw",
|
||||
knowledge_base_id: "kb-news",
|
||||
knowledge_id: "document-trump",
|
||||
chunk_id: "chunk-1",
|
||||
title: "特朗普生平.txt",
|
||||
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
|
||||
},
|
||||
],
|
||||
};
|
||||
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
|
||||
...firstFrame,
|
||||
retrieval_id: "raw-second",
|
||||
result_items: [
|
||||
{
|
||||
kind: "raw",
|
||||
knowledge_base_id: "kb-news",
|
||||
knowledge_id: "document-trump",
|
||||
chunk_id: "chunk-provider-remapped",
|
||||
title: "特朗普生平.txt",
|
||||
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const firstHtml = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[firstFrame]}
|
||||
dedupeScopeMessages={[]}
|
||||
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const repeatedHtml = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[repeatedFrame]}
|
||||
dedupeScopeMessages={[]}
|
||||
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
||||
0,
|
||||
);
|
||||
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
|
||||
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
|
||||
});
|
||||
@@ -1,525 +0,0 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
CirclePauseIcon,
|
||||
DatabaseIcon,
|
||||
Loader2Icon,
|
||||
PanelRightOpenIcon,
|
||||
SearchCheckIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
import {
|
||||
buildSmartKnowledgeRetrievalRounds,
|
||||
type KnowledgeRetrievalProgressFrame,
|
||||
type SmartKnowledgeRetrievalAction,
|
||||
type SmartKnowledgeRetrievalResultItem,
|
||||
} from "@/core/threads/smart-knowledge-retrieval";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
|
||||
if (action.status === "failed") return "检索失败";
|
||||
if (action.status === "interrupted") return "已中止";
|
||||
if (action.status === "running") {
|
||||
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
|
||||
}
|
||||
if (action.allResultsAlreadyShown) return "均为已展示结果";
|
||||
const hasUsableResults =
|
||||
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
|
||||
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
|
||||
if (action.kind === "search") {
|
||||
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
|
||||
}
|
||||
return typeof action.resultCount === "number"
|
||||
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
|
||||
: `${partialPrefix}读取完成`;
|
||||
}
|
||||
|
||||
function formatElapsedTime(elapsedMs: number): string {
|
||||
const normalized = Math.max(0, elapsedMs);
|
||||
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
|
||||
if (normalized < 60_000) {
|
||||
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
const roundedSeconds = Math.round(normalized / 1_000);
|
||||
const minutes = Math.floor(roundedSeconds / 60);
|
||||
const seconds = roundedSeconds % 60;
|
||||
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
function channelText(channel: string | undefined): string | null {
|
||||
if (!channel) return null;
|
||||
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
|
||||
if (channel === "provider") return "知识库 Wiki";
|
||||
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
|
||||
return channel;
|
||||
}
|
||||
|
||||
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
|
||||
if (action.status === "running") {
|
||||
return <Loader2Icon className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
if (action.status === "completed") {
|
||||
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
|
||||
}
|
||||
if (action.status === "failed") {
|
||||
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
|
||||
}
|
||||
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function resultDetailTarget(
|
||||
item: SmartKnowledgeRetrievalResultItem,
|
||||
): WeKnoraSourceTarget | null {
|
||||
if (!item.knowledgeBaseId) return null;
|
||||
if (item.kind === "wiki" && item.wikiSlug) {
|
||||
return {
|
||||
knowledgeBaseId: item.knowledgeBaseId,
|
||||
wikiSlug: item.wikiSlug,
|
||||
tab: "wiki",
|
||||
};
|
||||
}
|
||||
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
|
||||
return {
|
||||
knowledgeBaseId: item.knowledgeBaseId,
|
||||
chunkId: item.chunkId,
|
||||
documentId: item.documentId,
|
||||
tab: "documents",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function RetrievalAction({
|
||||
action,
|
||||
onOpenResult,
|
||||
}: {
|
||||
action: SmartKnowledgeRetrievalAction;
|
||||
onOpenResult?: (
|
||||
item: SmartKnowledgeRetrievalResultItem,
|
||||
target: WeKnoraSourceTarget,
|
||||
) => void;
|
||||
}) {
|
||||
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
|
||||
const channel = channelText(action.channel);
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
|
||||
data-retrieval-operation={action.operation}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{action.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs",
|
||||
action.status === "running"
|
||||
? "text-primary"
|
||||
: action.status === "completed"
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: action.status === "failed"
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<ActionStatusIcon action={action} />
|
||||
{actionStatusText(action)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{action.queries.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">查询</span>
|
||||
{action.queries.map((query) => (
|
||||
<span
|
||||
key={query}
|
||||
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
|
||||
>
|
||||
{query}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{typeof action.knowledgeBaseCount === "number" ? (
|
||||
<span>
|
||||
覆盖 {action.knowledgeBaseCount} 个知识库
|
||||
{action.knowledgeBases.length > 0
|
||||
? `:${action.knowledgeBases.join("、")}`
|
||||
: ""}
|
||||
</span>
|
||||
) : action.knowledgeBases.length > 0 ? (
|
||||
<span>知识库:{action.knowledgeBases.join("、")}</span>
|
||||
) : null}
|
||||
{typeof action.scannedPages === "number" && (
|
||||
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
|
||||
)}
|
||||
{typeof action.scannedChunks === "number" && (
|
||||
<span>扫描 {action.scannedChunks} 个原文片段</span>
|
||||
)}
|
||||
{action.kind === "read" && typeof action.total === "number" && (
|
||||
<span>共 {action.total} 个片段</span>
|
||||
)}
|
||||
{channel && <span>通道:{channel}</span>}
|
||||
{typeof action.elapsedMs === "number" && (
|
||||
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.allResultsAlreadyShown ? (
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
本次查询结果均已在前面展示
|
||||
</p>
|
||||
) : action.resultItems.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">
|
||||
{action.kind === "read" ? "读取结果" : "查询结果"}
|
||||
</div>
|
||||
{action.kind === "read" ? (
|
||||
<div className="space-y-2">
|
||||
{action.resultItems.map((item) => {
|
||||
const target = onOpenResult
|
||||
? resultDetailTarget(item)
|
||||
: null;
|
||||
const preview = item.preview?.trim().slice(0, 600);
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{item.title}
|
||||
</span>
|
||||
{target && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
|
||||
右侧查看
|
||||
<PanelRightOpenIcon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{preview && (
|
||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return target ? (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-retrieval-result-detail
|
||||
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
title={`在右侧查看:${item.title}`}
|
||||
onClick={() => onOpenResult?.(item, target)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={item.id}
|
||||
aria-disabled="true"
|
||||
data-retrieval-result-summary
|
||||
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
|
||||
title="该历史检索结果缺少稳定详情标识"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{action.resultItems.map((item) => {
|
||||
const target = onOpenResult
|
||||
? resultDetailTarget(item)
|
||||
: null;
|
||||
return target ? (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-retrieval-result-detail
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
title={`在右侧查看:${item.title}`}
|
||||
onClick={() => onOpenResult?.(item, target)}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
key={item.id}
|
||||
aria-disabled="true"
|
||||
data-retrieval-result-summary
|
||||
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
|
||||
title="该历史检索结果缺少稳定详情标识"
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{(action.resultCount ?? 0) > action.resultItems.length && (
|
||||
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
|
||||
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : action.resultTitles.length > 0 ? (
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
结果摘要:{action.resultTitles.join(";")}
|
||||
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{action.message && (
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
{action.message}
|
||||
</p>
|
||||
)}
|
||||
{action.error && (
|
||||
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
|
||||
{action.error}
|
||||
</p>
|
||||
)}
|
||||
{/* Provider warning codes stay in the structured trace for
|
||||
diagnostics. The old generic warning was misleading both when
|
||||
useful evidence existed and when the result set was empty. */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartKnowledgeRetrievalTrace({
|
||||
messages,
|
||||
progressFrames = [],
|
||||
dedupeScopeMessages,
|
||||
dedupeScopeProgressFrames,
|
||||
isLoading,
|
||||
onOpenResult,
|
||||
}: {
|
||||
messages: Message[];
|
||||
progressFrames?: KnowledgeRetrievalProgressFrame[];
|
||||
/**
|
||||
* The whole current user-turn group. Interleaved rendering splits one
|
||||
* retrieval invocation into one component, but result dedupe must still see
|
||||
* every invocation in the turn.
|
||||
*/
|
||||
dedupeScopeMessages?: Message[];
|
||||
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
|
||||
isLoading: boolean;
|
||||
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
|
||||
roundOffset?: number;
|
||||
onOpenResult?: (
|
||||
item: SmartKnowledgeRetrievalResultItem,
|
||||
target: WeKnoraSourceTarget,
|
||||
) => void;
|
||||
}) {
|
||||
const rounds = useMemo(
|
||||
() => {
|
||||
const localRounds = buildSmartKnowledgeRetrievalRounds(
|
||||
messages,
|
||||
progressFrames,
|
||||
isLoading,
|
||||
);
|
||||
if (!dedupeScopeMessages) return localRounds;
|
||||
|
||||
const globallyDedupedActions = new Map(
|
||||
buildSmartKnowledgeRetrievalRounds(
|
||||
dedupeScopeMessages,
|
||||
dedupeScopeProgressFrames ?? [],
|
||||
isLoading,
|
||||
)
|
||||
.flatMap((round) => round.actions)
|
||||
.map((action) => [action.id, action] as const),
|
||||
);
|
||||
return localRounds.map((round) => ({
|
||||
...round,
|
||||
actions: round.actions.map((action) => {
|
||||
const globalAction = globallyDedupedActions.get(action.id);
|
||||
return globalAction ? { ...globalAction, round: action.round } : action;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[
|
||||
dedupeScopeMessages,
|
||||
dedupeScopeProgressFrames,
|
||||
isLoading,
|
||||
messages,
|
||||
progressFrames,
|
||||
],
|
||||
);
|
||||
const actions = rounds.flatMap((round) => round.actions);
|
||||
// The chat can keep streaming its final answer after retrieval has already
|
||||
// finished. Only an unfinished retrieval action owns the "检索中" state;
|
||||
// the broader thread loading flag must not keep the spinner alive or block
|
||||
// the collapse control during answer generation.
|
||||
const isActive = actions.some((action) => action.status === "running");
|
||||
const hasFailure = actions.some((action) => action.status === "failed");
|
||||
const hasInterrupted = actions.some(
|
||||
(action) => action.status === "interrupted",
|
||||
);
|
||||
const rawHits = actions
|
||||
.filter((action) => action.surface === "raw" && action.kind === "search")
|
||||
.reduce(
|
||||
(total, action) =>
|
||||
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
|
||||
0,
|
||||
);
|
||||
const wikiHits = actions
|
||||
.filter((action) => action.surface === "wiki" && action.kind === "search")
|
||||
.reduce(
|
||||
(total, action) =>
|
||||
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
|
||||
0,
|
||||
);
|
||||
const elapsedActions = actions.filter(
|
||||
(action) => typeof action.elapsedMs === "number",
|
||||
);
|
||||
const cumulativeElapsedMs =
|
||||
elapsedActions.length > 0
|
||||
? elapsedActions.reduce(
|
||||
(total, action) => total + (action.elapsedMs ?? 0),
|
||||
0,
|
||||
)
|
||||
: undefined;
|
||||
// A live trace opens so newly completed result cards remain visible while
|
||||
// the final answer starts streaming. Replayed history starts collapsed.
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
isLoading && actions.length > 0,
|
||||
);
|
||||
const wasActive = useRef(isActive);
|
||||
const wasLoading = useRef(isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wasActive.current && isActive) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
if (wasLoading.current && !isLoading) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
wasActive.current = isActive;
|
||||
wasLoading.current = isLoading;
|
||||
}, [isActive, isLoading]);
|
||||
|
||||
// An in-flight retrieval always stays visible. A just-completed live trace
|
||||
// remains open and user-collapsible while the answer streams; replayed
|
||||
// history starts collapsed and can be reopened for inspection.
|
||||
const isOpen = isActive || isExpanded;
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!isActive) setIsExpanded(open);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-label="智能检索过程"
|
||||
aria-live={isActive ? "polite" : undefined}
|
||||
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
|
||||
data-smart-knowledge-retrieval-trace
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{isActive ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchCheckIcon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasFailure
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: hasInterrupted
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{isActive
|
||||
? "检索中"
|
||||
: hasFailure
|
||||
? "已完成(部分步骤失败)"
|
||||
: hasInterrupted
|
||||
? "检索已结束(部分步骤未完成)"
|
||||
: "检索完成"}
|
||||
</span>
|
||||
{!isActive && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
|
||||
>
|
||||
<span>{isOpen ? "收起" : "展开"}</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isActive && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
|
||||
{typeof cumulativeElapsedMs === "number"
|
||||
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{isActive && (
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
|
||||
{!isActive && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{actions.map((action) => (
|
||||
<RetrievalAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
onOpenResult={onOpenResult}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -5,18 +5,12 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { authStorageRemove } from "@/core/auth/scoped-storage";
|
||||
import { EXTERNAL_LOGIN_STORAGE_KEYS } from "@/core/auth/external-login";
|
||||
import { clearLoginCredentials } from "@/core/auth/login-credentials";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { clearYLogToken } from "@/core/log/y-log-token";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { PasswordSettings } from "./password-settings";
|
||||
|
||||
function roleLabel(role?: string) {
|
||||
if (role === "admin") return "管理员";
|
||||
@@ -60,23 +54,17 @@ export function AccountSettingsPage() {
|
||||
});
|
||||
clearStoredAuth();
|
||||
clearYLogToken();
|
||||
for (const key of [
|
||||
"userInfo", "userId", "tools_token", "access_token",
|
||||
...Object.values(EXTERNAL_LOGIN_STORAGE_KEYS),
|
||||
]) authStorageRemove(key);
|
||||
clearLoginCredentials();
|
||||
// Match the rest of the app's logout flow: blow away the cookie session
|
||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||
// so any cached query state goes too.
|
||||
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
|
||||
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.location.assign(`${window.location.pathname}#/login`);
|
||||
window.location.assign("/");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -118,8 +106,6 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 800 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user