Compare commits

..
440 changed files with 46560 additions and 14184 deletions

No files matched your search

+20 -8
View File
@@ -22,9 +22,7 @@ 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/.rspack-cache/
frontend-web/dist-firefox52/
frontend-web/vite-dev.err.log
# Frontend local packages/archives/docs (non-source)
frontend-web/pnpm-lock.yaml
@@ -92,6 +90,7 @@ 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
# 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/
@@ -100,8 +99,12 @@ 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_migration_compatibility.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
@@ -118,6 +121,15 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py
!offline-backend-20260512/backend/tests/test_smart_reasoning_retrieval_middleware.py
!offline-backend-20260512/backend/tests/test_knowledge_retrieval_settings.py
!offline-backend-20260512/backend/tests/test_wiki_generation_config.py
!offline-backend-20260512/backend/tests/test_wiki_directory_constraints.py
!offline-backend-20260512/backend/tests/test_llmwiki_parent_directory_routing.py
!offline-backend-20260512/backend/tests/test_llmwiki_loading.py
!offline-backend-20260512/backend/tests/test_weknora_embed_script.py
!offline-backend-20260512/backend/tests/test_weknora_static.py
!offline-backend-20260512/backend/tests/test_llmwiki_advanced_retrieval.py
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
@@ -177,6 +189,8 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
# Per-account question recommendation preferences.
!offline-backend-20260512/backend/tests/test_user_preferences.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
@@ -204,6 +218,7 @@ git-clone/
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
/deerflow-frontend-dist-*.zip
# Local dependency caches and backend scratch archives
/.pnpm-store/
@@ -242,8 +257,5 @@ offline-backend-20260512/backend/uv.lock
# Python bytecode cache
__pycache__/
# misc
.DS_Store
# Local Firefox 52 test profiles
.firefox52-profile*/
# misc
.DS_Store
+2
View File
@@ -98,6 +98,8 @@ 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/`
+4 -1
View File
@@ -112,6 +112,8 @@ 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`
@@ -123,7 +125,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`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级登录口令与密码**已统一,个人口令优先于系统共享口令;新密码保存哈希,旧明文口令兼容校验。管理员重置使用 `PUT /api/v1/auth/users/{id}/login-password`(别名 `/reset-password`),同时更新两种历史凭据字段并撤销旧会话,不再支持清空密码或读取新密码明文。未设置个人密码时使用配置共享口令(默认 `123ewq`);登录开关关闭时保留旧入口兼容行为,开启后禁止免密进入。详见上方「账号登录、自主注册与单点登录」。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
@@ -131,6 +133,7 @@ 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/`
-52
View File
@@ -1,52 +0,0 @@
<!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>
@@ -1,72 +0,0 @@
"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);
};
@@ -1,44 +0,0 @@
"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);
});
@@ -1,7 +0,0 @@
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],
};
@@ -1,15 +0,0 @@
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,
],
};
@@ -1,229 +0,0 @@
"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;
};
@@ -1,52 +0,0 @@
"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}",
);
});
@@ -1,55 +0,0 @@
# 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 与未捕获异常,数据写入流程由专门测试数据验收。
@@ -1,56 +0,0 @@
# 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 或实际业务数据写入的流程,仍应在对应部署环境补充人工验收。
@@ -1,579 +0,0 @@
# 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 丢失和内存异常。
- [ ] 新增功能与依赖具备持续兼容检查。
- [ ] 离线环境可以重复安装、构建和部署。
@@ -1,5 +1,7 @@
# 技能知识归纳与“知识梳理”助手知识库
助手知识库的目录、正文、来源和任务记录读取已提交快照,不等待后台导入的全局写锁,也不重复统计整库内容。导入仍在提交前完成实体及 Wiki 对齐,旧数据在检索、导出时保留兼容对齐。页面和展示权限的读取支持取消及 30 秒超时,失败显示可重试错误,避免一直加载;大目录分页失败后停止自动续页并保留已加载正文。来源列表仅在打开「来源与同步」或「导入 / 导出记录」时请求。
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
+1 -2
View File
@@ -93,8 +93,7 @@ frontend-web/
| 脚本 | 命令 | 说明 |
|---|---|---|
| `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 dev` | `vite` | 启动开发服务器(默认端口 5173;项目实际通过仓库根脚本统一为 5174) |
| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) |
| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 |
| `pnpm typecheck` | `tsc --noEmit` | 仅做 TS 类型检查,不输出文件 |
+28
View File
@@ -47,6 +47,34 @@
} 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" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
+554 -5062
View File
File diff suppressed because it is too large. Load diff
+11 -51
View File
@@ -4,40 +4,23 @@
"private": true,
"type": "module",
"scripts": {
"dev": "rspack serve --config rspack.firefox52.config.mjs --mode development",
"dev:vite": "vite",
"dev:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode development",
"dev": "vite",
"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: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: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:auth-entry": "node scripts/test-auth-entry.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: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"
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@@ -91,35 +74,31 @@
"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": "4.7.1",
"maplibre-gl": "^6.9.0",
"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",
"pepjs": "0.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"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",
@@ -137,42 +116,23 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@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",
"@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-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",
"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",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2",
"web-streams-polyfill": "4.3.0"
"vite-plugin-source-identifier": "^1.1.2"
},
"overrides": {
"@radix-ui/react-slot": "1.3.3"
},
"packageManager": "pnpm@10.26.2",
"browserslist": [
"Firefox >= 52"
]
"packageManager": "pnpm@10.26.2"
}
+8 -4
View File
@@ -1,7 +1,11 @@
import tailwindcss from "tailwindcss";
import darkDescendant from "./scripts/postcss-dark-descendant.mjs";
import autoprefixer from "autoprefixer";
import tailwindcss from "@tailwindcss/postcss";
import legacyColorFallback from "./scripts/postcss-legacy-color-fallback.mjs";
import lightningCssLegacyChrome from "./scripts/postcss-lightningcss-chrome109.mjs";
export default {
plugins: [tailwindcss, darkDescendant, autoprefixer],
plugins: [
tailwindcss(),
legacyColorFallback(),
lightningCssLegacyChrome(),
],
};
+5
View File
@@ -155,6 +155,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 对话历史页「收藏」右侧的「清空」按钮。
// "false" 隐藏;不填、删掉这一项,或填其它值,都显示。
// 确认后会删除当前账号的全部对话,以及该账号下所有智能体的聊天记录。
VITE_CHAT_HISTORY_CLEAR_VISIBLE: "true",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
-335
View File
@@ -1,335 +0,0 @@
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,
};
};
@@ -1,122 +0,0 @@
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;
@@ -1,98 +0,0 @@
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())。`,
);
@@ -1,48 +0,0 @@
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)。`,
);
@@ -1,31 +0,0 @@
/**
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
*
* - 特异性不变:`:is(.dark *)` 取最高参数 = (0,1,0),剥离后前置 `.dark `
* 同样 +(0,1,0),两种形式均为 (0,2,0),级联行为一致。
* - `.dark` 挂在 <html> 上、是全局祖先,前置到组合器最左侧语义等价
* (group-hover:dark:* 等组合下同样成立)。
* - 堆叠变体(dark:marker:* 等)生成的 `X *:is(.dark *)::marker` 会被
* 改写为 `.dark X *::marker`,保持合法。
*/
const TOKEN = ":is(.dark *)";
export default function darkDescendant() {
return {
postcssPlugin: "postcss-dark-descendant",
Rule(rule) {
if (!rule.selector.includes(TOKEN)) return;
rule.selector = rule.selector
.split(",")
.map((sel) => {
const trimmed = sel.trim();
if (!trimmed.includes(TOKEN)) return trimmed;
return ".dark " + trimmed.split(TOKEN).join("");
})
.join(", ");
},
};
}
darkDescendant.postcss = true;
@@ -1,164 +0,0 @@
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 };
@@ -1,47 +0,0 @@
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,12 +2,10 @@ 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 the supported legacy browser syntax for both dev and
* production. Cascade layers are flattened by the preceding PostCSS plugin.
* resulting CSS to Chrome 100-compatible syntax for both dev and production.
*/
export default function lightningCssLegacyChrome() {
return {
@@ -17,7 +15,7 @@ export default function lightningCssLegacyChrome() {
const output = transform({
filename,
code: Buffer.from(root.toString()),
targets: { chrome: LEGACY_CHROME, firefox: LEGACY_FIREFOX },
targets: { chrome: LEGACY_CHROME },
minify: false,
});
const compiled = postcss.parse(output.code.toString(), { from: filename });
@@ -1,48 +0,0 @@
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;
@@ -1,377 +0,0 @@
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;
@@ -1,163 +0,0 @@
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;
-294
View File
@@ -1,294 +0,0 @@
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;
+34
View File
@@ -0,0 +1,34 @@
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 testNames = ["login-entry", "session-validation"];
const outputDirectory = path.join(root, ".runtime/unit-tests");
try {
await build({
absWorkingDir: root,
entryPoints: testNames.map((name) => `src/core/auth/${name}.test.ts`),
outdir: outputDirectory,
outExtension: { ".js": ".mjs" },
bundle: true,
platform: "node",
format: "esm",
});
} finally {
stop();
}
const result = spawnSync(process.execPath, ["--test", ...testNames.map((name) => path.join(outputDirectory, `${name}.test.mjs`))], {
cwd: root,
stdio: "inherit",
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
+114 -79
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo } from "react";
import { lazy, Suspense, useEffect, useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { RouteLoadingFallback } from "@/components/route-loading";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { RequireAccount } from "@/core/auth/require-account";
import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale";
@@ -25,28 +27,47 @@ import {
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
} from "@/position-roundtable/demo/position-roundtable-demo-config";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
const PublicScheduledTaskFullscreenPage = lazy(() =>
import("./pages/PublicScheduledTaskPage").then((module) => ({
default: module.PublicScheduledTaskFullscreenPage,
})),
);
const PublicSharePage = lazy(() => import("./pages/PublicSharePage"));
const PublicLeaderboardPage = lazy(() => import("./pages/PublicLeaderboardPage"));
const PublicRoundtableSharePage = lazy(() => import("./pages/PublicRoundtableSharePage"));
const EmbedChatPage = lazy(() => import("./pages/EmbedChatPage"));
const EmbedRoundtablePage = lazy(() => import("./pages/EmbedRoundtablePage"));
const SiteStudioPage = lazy(() => import("./pages/site-studio/SiteStudioPage"));
const DashboardStandalonePage = lazy(
() => import("@/roundtable-planning/pages/DashboardStandalonePage"),
);
const PositionRoundtablePage = lazy(
() => import("@/position-roundtable/pages/PositionRoundtablePage"),
);
const WeKnoraWikiPageView = lazy(
() => import("@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"),
);
const ChatRuntime = lazy(() =>
import("@/components/workspace/chat-runtime").then((module) => ({
default: module.ChatRuntime,
})),
);
const NotFoundPage = lazy(() =>
import("./pages/NotFoundPage").then((module) => ({
default: module.NotFoundPage,
})),
);
const OfficialDeerFlowEntry = lazy(() => import("./pages/OfficialDeerFlowEntry"));
const PUBLIC_EMBED_PREFIXES = [
"/share/",
@@ -145,72 +166,86 @@ export function App() {
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
);
const pageElement = positionStandaloneMode ? (
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
<RequireAccount allowLegacyAnonymous>
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
</RequireAccount>
) : (
<PageRoutes />
<RequireAccount><PageRoutes /></RequireAccount>
);
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route
path="/login/:username"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route
path="/login/:username"
element={
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route
path="/register"
element={
positionStandaloneMode ? standaloneLoginRedirect : <RegisterPage />
}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<RequireAccount><DashboardStandalonePage /></RequireAccount>}
/>
<Route path="/official/*" element={<RequireAccount><OfficialDeerFlowEntry /></RequireAccount>} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<RequireAccount><SiteStudioPage /></RequireAccount>} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</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,9 +65,8 @@ import {
type CompositionEventHandler,
createContext,
type FormEvent,
type FormEventHandler,
forwardRef,
type HTMLAttributes,
type FormEventHandler,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
@@ -1203,43 +1202,43 @@ export const PromptInputTools = ({
<div className={cn("flex items-center gap-1", className)} {...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 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 PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<DropdownMenu {...props} />
);
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 PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
export type PromptInputActionMenuContentProps = ComponentProps<
typeof DropdownMenuContent
@@ -1275,20 +1274,24 @@ export const PromptInputSubmit = ({
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<InputGroupButton
aria-label="Submit"
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
let ariaLabel = "发送消息";
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
ariaLabel = "正在发送";
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
ariaLabel = "停止生成";
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
ariaLabel = "重新发送";
}
return (
<InputGroupButton
aria-label={ariaLabel}
className={cn(
"transition-all duration-300",
status === "streaming" &&
@@ -31,6 +31,11 @@ import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
import {
normalizeV2UploadSources,
type StudioUploadSource,
type V2UploadResponse,
} from "@/core/studio/document-upload";
// ── 适配层 ─────────────────────────────────────────────────
@@ -86,22 +91,7 @@ interface DocumentUploadDialogContextType {
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}
interface listDataItem {
@@ -224,7 +214,7 @@ export const DocumentUploadDialogProvider: FC<{
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
@@ -353,27 +343,23 @@ const DocumentUploadPopupContent: FC<{
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as UploadResponse;
const data = await res.json() as V2UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
onUploadComplete?.(normalizeV2UploadSources(data));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
toast.error("上传失败,请重试");
const payload = await res.json().catch(() => null) as { detail?: string } | null;
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
}
} catch {
toast.error("上传失败,请重试");
@@ -410,7 +396,6 @@ const DocumentUploadPopupContent: FC<{
selectedSearchSources.map((s) => ({
id: undefined,
title: s.title,
task_id: undefined,
status: "success" as const,
}))
);
+34 -18
View File
@@ -1,14 +1,19 @@
"use client";
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useLocalSettings } from "@/core/settings";
import {
applyFontScaleToDocument,
normalizeFontScale,
normalizeFontScaleMode,
} from "@/core/ui/page-zoom";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
@@ -21,25 +26,36 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1.25, label: "特大", px: 20 },
];
export const FONT_SCALE_MODE_OPTIONS: {
value: "text" | "page";
label: string;
description: string;
}[] = [
{
value: "text",
label: "仅文字",
description: "只放大文字,间距和布局保持原样。",
},
{
value: "page",
label: "整页缩放",
description: "文字、间距、图标和写死的像素一起放大。",
},
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
const { fontScale, fontScaleMode } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
useLayoutEffect(() => {
applyFontScaleToDocument(
normalizeFontScaleMode(fontScaleMode),
normalizeFontScale(fontScale),
);
}, [fontScale, fontScaleMode]);
return null;
}
@@ -1,3 +1,5 @@
import { toCssPx } from "@/core/ui/page-zoom";
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
+8 -8
View File
@@ -4,19 +4,19 @@
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
flex-shrink: 0;
}
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
.sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
}
.sidebar-contents {
@@ -160,8 +160,8 @@
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
@@ -194,7 +194,7 @@ export function PageSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
+3 -1
View File
@@ -500,7 +500,9 @@ function LegacyAccordionSidebar({
/>
<aside
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
headerHidden
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
} ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
@@ -0,0 +1,16 @@
/** Lightweight, dependency-free fallback used while a route chunk loads. */
export function RouteLoadingFallback() {
return (
<div
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
role="status"
aria-live="polite"
>
<span
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
aria-hidden="true"
/>
<span className="text-sm">正在加载页面…</span>
</div>
);
}
@@ -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-[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",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
@@ -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-[var(--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-(--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-[var(--radix-hover-card-content-transform-origin)] rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
+18 -16
View File
@@ -97,22 +97,24 @@ const inputGroupButtonVariants = cva(
},
);
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 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}
/>
);
}
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, 0.8) 0%,
rgba(132, 0, 255, 0.4) 30%,
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
transparent 60%
);
border-radius: inherit;
@@ -165,7 +165,7 @@
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
opacity: var(--glow-intensity);
opacity: 1;
transition: opacity 0.3s ease;
z-index: 1;
}
+1 -1
View File
@@ -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-[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]",
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+11 -11
View File
@@ -178,7 +178,7 @@ function Sidebar({
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
"bg-sidebar text-sidebar-foreground flex h-full w-(--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-[var(--sidebar-width)] p-0 [&>button]:hidden"
className="bg-sidebar text-sidebar-foreground w-(--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-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--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)+1rem)]"
: "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]",
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"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)]",
"fixed bottom-0 z-10 hidden w-(--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)+1rem+2px)]"
: "group-data-[collapsible=icon]:w-[var(--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)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--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-[var(--skeleton-width)] flex-1"
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
+2 -2
View File
@@ -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-[calc(var(--gap)*0.25rem)] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className,
)}
{...props}
+1 -1
View File
@@ -54,7 +54,7 @@ const TooltipContent = React.forwardRef<
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
@@ -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">
@@ -1,8 +1,10 @@
"use client";
import { Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
@@ -17,6 +19,11 @@ export function UserManagementPage() {
title={t.adminUsers.title}
description={t.adminUsers.description}
>
<div className="mb-4 flex justify-end">
<Button variant="outline" asChild>
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
</Button>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -172,7 +172,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
return (
// 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。
<aside className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
{/* 智能体(上半,可收起)。展开时:标题栏 + 来源下拉/名称搜索 + 列表;
收起时只留标题栏,下方「最近的对话」占满剩余空间。 */}
<div
@@ -291,7 +291,7 @@ export function AgentModuleSidebar({
// 浅色保持白底。其余颜色已走语义 token 自适配。
<aside
className={cn(
"flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
"flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
className,
)}
>
@@ -138,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,11 +66,6 @@ import {
DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
@@ -451,15 +446,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;
@@ -482,8 +477,8 @@ export function ArtifactFileDetail({
!isArtifactContentLoading;
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
@@ -508,16 +503,16 @@ export function ArtifactFileDetail({
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
useEffect(() => {
if (viewMode !== "edit" || canUseAiEditor) return;
@@ -562,13 +557,20 @@ export function ArtifactFileDetail({
presentationId: string,
event: DocumentRewriteStreamEvent,
) => {
if (!rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
if (!rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -629,10 +631,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -648,9 +651,13 @@ export function ArtifactFileDetail({
case "cancelled":
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
@@ -672,10 +679,18 @@ export function ArtifactFileDetail({
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary
: null;
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: historical.id,
outputPath: typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath,
fileName: getFileName(
typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath ?? filepath,
),
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
@@ -696,11 +711,14 @@ export function ArtifactFileDetail({
summary: historicalSummary,
warnings: historicalSummary?.validationWarnings ?? [],
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed"
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
: null,
diagnostic: historical.diagnostic ?? null,
});
}
const result = latest.result;
@@ -709,36 +727,25 @@ export function ArtifactFileDetail({
: null;
const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
const outputPath = typeof result.outputPath === "string"
? result.outputPath
: latest.outputPath;
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: latest.id,
outputPath,
fileName: getFileName(outputPath ?? filepath),
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
rewrittenContent,
committedHash,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
});
if (!presentationId || !rewritePresentation) return;
setDocumentRewritePresentationId(presentationId);
@@ -747,32 +754,22 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, {
stage: result.status === "conflict" ? "conflict" : "completed",
summary,
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
});
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined },
);
}
return;
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
error: result.status === "conflict" ? "改写版文件未生成。" : null,
});
return;
}
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
});
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
error: latest.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
});
return;
}
@@ -794,35 +791,22 @@ export function ArtifactFileDetail({
signal,
),
onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined },
);
},
},
);
onCommitted: (rewritten, { committedHash, outputPath }) => {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
},
onFailure: ({ error, diagnostic }) => {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
},
},
);
} catch {
// Existing artifacts remain available even if history hydration fails.
}
@@ -945,8 +929,8 @@ export function ArtifactFileDetail({
return;
}
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
return;
}
@@ -956,9 +940,9 @@ export function ArtifactFileDetail({
}
setViewMode("code");
}, [
documentRewriteActive,
isPreviewStreaming,
}, [
documentRewriteActive,
isPreviewStreaming,
isSupportPreview,
language,
writeFileInFlight,
@@ -969,7 +953,7 @@ export function ArtifactFileDetail({
return;
}
if (writeFileInFlight || documentRewriteActive) {
if (writeFileInFlight || documentRewriteActive) {
setViewMode("code");
return;
}
@@ -981,10 +965,10 @@ export function ArtifactFileDetail({
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
isSupportPreview,
writeFileInFlight,
]);
@@ -1145,16 +1129,17 @@ export function ArtifactFileDetail({
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
@@ -1173,12 +1158,7 @@ export function ArtifactFileDetail({
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent);
const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
@@ -1210,14 +1190,21 @@ export function ArtifactFileDetail({
expectedHash,
},
{
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -1281,9 +1268,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -1293,77 +1282,48 @@ export function ArtifactFileDetail({
versionId: event.versionId ?? null,
});
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "error":
// The sandbox retains the visible draft and exposes manual
// save instead of showing an error state to the user.
console.error("AI 全文改写接口失败:", event.message);
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "cancelled":
rewritePresentation.update(presentationId, {
stage: "cancelled",
error: event.message,
});
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
},
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
}
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: rewritten, url: undefined },
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
},
onCommitted: (rewritten, { committedHash, outputPath }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
}
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
},
onFailure: ({ error, diagnostic }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
}
},
},
);
},
@@ -1381,15 +1341,37 @@ export function ArtifactFileDetail({
],
);
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
if (documentRewritePresentationId && rewritePresentation) {
rewritePresentation.update(documentRewritePresentationId, {
stage: "cancelled",
error: "已停止全文改写,原文件未被修改。",
});
}
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
// The docked conversation composer is outside this artifact pane. Register
// the same cancellation path so its standard square stop button can abort
// both the local SSE reader and the durable rewrite job.
useEffect(() => {
if (
!documentRewriteActive ||
!documentRewritePresentationId ||
!rewritePresentation
) {
return;
}
return rewritePresentation.registerCancellation(
documentRewritePresentationId,
handleCancelDocumentRewrite,
);
}, [
documentRewriteActive,
documentRewritePresentationId,
handleCancelDocumentRewrite,
rewritePresentation,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
@@ -1532,7 +1514,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">
@@ -1628,19 +1610,25 @@ export function ArtifactFileDetail({
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)}
/>
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成独立改写版文件,原文件保持不变;点击停止"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => {
if (documentRewriteActive) {
handleCancelDocumentRewrite();
return;
}
setDocumentRewriteDialogOpen(true);
}}
/>
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
@@ -1766,27 +1754,6 @@ export function ArtifactFileDetail({
正在改写 Markdown
</div>
)}
{documentRewriteActive && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<LoaderIcon className="size-3.5 animate-spin" />
{documentRewrite.run.stage === "requirements"
? "正在理解改写需求,原文件未被修改"
: documentRewrite.run.stage === "validation"
? "正在校验 Markdown"
: documentRewrite.run.stage === "committing"
? "正在安全替换原文件"
: "正在写入临时草稿 · " +
documentRewrite.run.writtenCharCount.toLocaleString() +
" 字"}
<button
type="button"
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
onClick={handleCancelDocumentRewrite}
>
停止
</button>
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
@@ -1797,7 +1764,7 @@ export function ArtifactFileDetail({
滑动到底部并继续自动下滑
</button>
)}
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
@@ -1854,16 +1821,16 @@ export function ArtifactFileDetail({
? (displayContent ?? "")
: sandboxDisplayText
}
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
isStreaming={writeFileInFlight || documentRewriteActive}
isStreaming={writeFileInFlight || documentRewriteActive}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useUploadPolicy } from "@/core/uploads";
/**
* Shared providers required by every full chat surface.
*
* Keep this in a small standalone module: importing it from a route must not
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
*/
export function ChatRuntime({ children }: { children: ReactNode }) {
const { data: uploadPolicy } = useUploadPolicy();
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider uploadPolicy={uploadPolicy}>
{children}
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -815,13 +815,13 @@ const ChatBox: React.FC<{
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-[var(--container-width-sm)] flex-col justify-center p-4 pt-8">
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-[var(--container-width-sm)] p-4 pt-12"
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
@@ -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}
@@ -1,8 +1,10 @@
"use client";
import type { ChatStatus } from "ai";
import {
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
@@ -61,12 +63,23 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
REASONING_EFFORTS,
resolveModelReasoningEffort,
type ReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -124,6 +137,7 @@ export function InputBox({
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
@@ -146,7 +160,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
@@ -162,7 +176,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
},
) => void;
@@ -197,6 +211,15 @@ 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;
smartAvailabilityReason: string;
/** The administrator policy used only when a new conversation is created. */
smartIsDefault: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
@@ -216,11 +239,25 @@ export function InputBox({
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -235,6 +272,7 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
@@ -253,12 +291,20 @@ 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,
);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.reasoning_effort === nextReasoningEffort &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
@@ -268,6 +314,7 @@ export function InputBox({
...context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: nextReasoningEffort,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
@@ -292,6 +339,31 @@ export function InputBox({
[selectedModel],
);
const selectedReasoningEffort = useMemo(
() =>
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
),
[context.reasoning_effort, supportReasoningEffort],
);
const reasoningEffortIndex = Math.max(
0,
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
);
const reasoningEffortThumbLeft =
reasoningEffortIndex === 0
? "0.5rem"
: reasoningEffortIndex === 1
? "50%"
: "calc(100% - 0.5rem)";
const reasoningEffortLabel =
selectedReasoningEffort === "low"
? t.inputBox.reasoningEffortLow
: selectedReasoningEffort === "medium"
? t.inputBox.reasoningEffortMedium
: t.inputBox.reasoningEffortXHigh;
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
@@ -302,7 +374,11 @@ export function InputBox({
...context,
model_name,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
reasoning_effort: resolveModelReasoningEffort(
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
),
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
@@ -321,18 +397,20 @@ export function InputBox({
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
),
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
[
onContextChange,
context,
models,
supportThinking,
supportReasoningEffort,
],
);
const handleSingleRoundRagSelect = useCallback(() => {
@@ -362,7 +440,7 @@ export function InputBox({
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
(effort: ReasoningEffort) => {
onContextChange?.({
...context,
reasoning_effort: effort,
@@ -386,8 +464,13 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
@@ -403,12 +486,16 @@ export function InputBox({
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
onStop?.();
return;
}
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (composerStatus === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", {
description: "已保留您输入的内容。",
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
}
if (!hasPromptInputPayload(message)) {
return;
}
@@ -421,7 +508,11 @@ 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) {
if (
resolvedModelName &&
(context.model_name !== resolvedModelName ||
context.reasoning_effort !== selectedReasoningEffort)
) {
onContextChange?.({
...context,
model_name: resolvedModelName,
@@ -429,6 +520,7 @@ export function InputBox({
context.mode,
selectedModel?.supports_thinking ?? false,
),
reasoning_effort: selectedReasoningEffort,
execution_profile: selectedModel?.execution_profile,
});
setTimeout(() => onSubmit?.(message), 0);
@@ -439,26 +531,40 @@ export function InputBox({
},
[
context,
onContextChange,
onSubmit,
onStop,
resolvedModelName,
onContextChange,
onSubmit,
composerStatus,
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 handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
@@ -471,7 +577,7 @@ export function InputBox({
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
[composerStatus, requestFormSubmit, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
@@ -503,6 +609,7 @@ export function InputBox({
}, [pendingSuggestion, requestFormSubmit, textInput]);
const followupsPresent =
followupsEnabled &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
@@ -524,11 +631,20 @@ export function InputBox({
return () => followupsPresenceChangeRef.current?.(false);
}, []);
useEffect(() => {
if (followupsEnabled) return;
setFollowups([]);
setFollowupsLoading(false);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
}, [followupsEnabled]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
if (!followupsEnabled || !wasStreaming || streaming) {
return;
}
@@ -580,6 +696,7 @@ export function InputBox({
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
if (controller.signal.aborted) return;
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
@@ -587,14 +704,19 @@ export function InputBox({
setFollowups(suggestions);
})
.catch(() => {
if (controller.signal.aborted) return;
setFollowups([]);
})
.finally(() => {
if (controller.signal.aborted) return;
setFollowupsLoading(false);
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return () => {
controller.abort();
setFollowupsLoading(false);
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
@@ -726,8 +848,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}
@@ -746,7 +868,7 @@ export function InputBox({
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="!px-2" />
<PromptInputActionMenuTrigger className="px-2!" />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments
label={t.inputBox.addAttachments}
@@ -756,9 +878,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",
)}
/>
)}
@@ -771,8 +893,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
@@ -800,8 +922,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
@@ -817,36 +939,42 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"!gap-1.5 rounded-full !px-3 shadow-none",
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled &&
!frontendRagActive &&
!smartKnowledgeRetrievalActive &&
!hideContextTools && (
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact &&
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
)}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
@@ -859,8 +987,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
@@ -896,8 +1024,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"
@@ -1139,122 +1267,61 @@ 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>
)}
{!frontendRagActive &&
supportReasoningEffort &&
context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"rounded-lg border border-border px-2.5! py-1! text-[13px]",
)}
>
<div className="flex items-center gap-1.5 text-xs font-medium">
<SparklesIcon className="size-3" />
<span className="text-foreground">
{reasoningEffortLabel}
</span>
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-56 p-3">
<div className="flex items-center justify-between gap-3 px-1">
<span className="text-xs text-muted-foreground">
{t.inputBox.reasoningEffortSelect}
</span>
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
{reasoningEffortLabel}
</span>
</div>
<div className="mt-2 px-1">
<div className="relative h-9 w-full">
<input
type="range"
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
aria-valuetext={reasoningEffortLabel}
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
value={reasoningEffortIndex}
min={0}
max={2}
step={1}
onChange={(event) => {
const effort =
REASONING_EFFORTS[Number(event.currentTarget.value)];
if (effort) handleReasoningEffortSelect(effort);
}}
/>
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
<div
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
style={{ left: reasoningEffortThumbLeft }}
/>
</div>
</div>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
@@ -1266,6 +1333,100 @@ export function InputBox({
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
: knowledgeRetrieval.smartAvailabilityReason
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
智能检索
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-14 items-start rounded-lg py-2"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
经典检索
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
沿用原有知识库检索与回答方式
</div>
</div>
{knowledgeRetrieval.mode === "classic" && (
<CheckIcon className="mt-0.5 size-4 text-primary" />
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<p
className={cn(
"px-2 py-1 text-xs leading-5",
knowledgeRetrieval.smartAvailable
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-300",
)}
>
{knowledgeRetrieval.smartAvailabilityReason}
</p>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
@@ -1299,12 +1460,11 @@ export function InputBox({
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton
data-firefox52-smoke="model-selector"
className={cn(
<PromptInputButton
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">
@@ -1351,11 +1511,21 @@ export function InputBox({
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
status={status}
/>
)}
disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline"
status={composerStatus}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/>
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
@@ -1465,7 +1635,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,8 +20,14 @@ import {
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding() {
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
export function useOrdinaryKnowledgeLanding(enabled = true) {
return useQuery({
queryKey: LANDING_KEY,
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
enabled,
staleTime: 30_000,
retry: false,
});
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
@@ -31,7 +37,8 @@ export function KnowledgeProductNav() {
const navigate = useNavigate();
const client = useQueryClient();
// const assistantVisibility = useAssistantKnowledgeVisibility();
const landing = useOrdinaryKnowledgeLanding();
const isAdmin = getStoredAuth()?.system_role === "admin";
const landing = useOrdinaryKnowledgeLanding(isAdmin);
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
@@ -39,7 +46,6 @@ 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,6 +22,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
@@ -46,14 +48,14 @@ export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
`「${name}」包含哪些核心主题?请按重要性说明。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
@@ -426,10 +428,13 @@ export function LlmWikiKnowledgeSelector({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const assistantAccess = useAssistantKnowledgeAccess({
enabled: onAssistantChange !== undefined,
enabled:
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
});
const assistantAllowed =
onAssistantChange !== undefined && assistantAccess.canAccess;
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
onAssistantChange !== undefined &&
assistantAccess.canAccess;
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
@@ -489,7 +494,12 @@ export function LlmWikiKnowledgeSelector({
[selectedAssistantNames, selectedNames],
);
const selectedConfiguredQuestions = useMemo(
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
() =>
userFacingKnowledgeSuggestedQuestions(
value.flatMap(
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
),
),
[rows, value],
);
const suggestedQuestions = useMemo(
@@ -549,7 +559,11 @@ export function LlmWikiKnowledgeSelector({
value={value}
assistantValue={effectiveAssistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange ?? IGNORE_ASSISTANT_CHANGE}
onAssistantChange={
assistantAllowed
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
: IGNORE_ASSISTANT_CHANGE
}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -4,6 +4,7 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
CopyIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
@@ -14,6 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
import {
Collapsible,
CollapsibleContent,
@@ -22,6 +24,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { copyTextToClipboard } from "@/core/clipboard";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
@@ -42,7 +45,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "writing", label: "生成改写版 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
@@ -221,6 +224,9 @@ export function DocumentRewriteTimeline({
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const diagnosticText = item.diagnostic
? JSON.stringify(item.diagnostic, null, 2)
: "";
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
@@ -370,7 +376,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -440,13 +446,13 @@ export function DocumentRewriteTimeline({
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
</p>
) : null
)}
@@ -583,9 +589,42 @@ export function DocumentRewriteTimeline({
)}
{item.error && (
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<XCircleIcon className="size-4 shrink-0" />
{item.error}
<div className="border-t px-5 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
<span>{item.error}</span>
</div>
{item.diagnostic && (
<Collapsible className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<CollapsibleTrigger asChild>
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
<ChevronDownIcon className="size-3.5" />
查看后台诊断
</Button>
</CollapsibleTrigger>
<Button
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={() => {
void copyTextToClipboard(diagnosticText)
.then(() => toast.success("诊断信息已复制"))
.catch(() => toast.error("复制诊断信息失败"));
}}
size="sm"
type="button"
variant="outline"
>
<CopyIcon className="size-3.5" />
复制诊断
</Button>
</div>
<CollapsibleContent>
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
{diagnosticText}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
)}
<DocumentRewriteDiffDialog
@@ -598,3 +637,36 @@ export function DocumentRewriteTimeline({
</article>
);
}
/** A standalone chat row for a sandbox document-rewrite task. */
export function DocumentRewriteMessage({
item,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
item: DocumentRewritePresentation;
threadId: string;
onOpenArtifact?: (filepath: string) => void;
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
const completed = item.stage === "completed";
return (
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
<DocumentRewriteTimeline item={item} />
{completed && artifactPath && (
<ArtifactFileList
fileDescriptions={{
[artifactPath]: "AI 改写版 Markdown 文件",
}}
files={[artifactPath]}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
)}
</section>
);
}
@@ -223,6 +223,7 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
@@ -235,6 +236,7 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
@@ -350,6 +352,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -360,6 +363,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -370,19 +374,42 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -408,12 +435,17 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -848,8 +880,8 @@ function ArtifactWriteToolCall({
)}
</ChainOfThoughtStep>
);
}
}
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
@@ -892,6 +924,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
@@ -956,11 +992,23 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
);
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -1027,6 +1075,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -32,6 +32,7 @@ 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,
@@ -40,11 +41,23 @@ 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 { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
@@ -53,9 +66,10 @@ import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { DocumentRewriteMessage, 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,
@@ -273,32 +287,64 @@ export function MessageList({
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
item: SmartKnowledgeRetrievalResultItem;
target: WeKnoraSourceTarget;
} | null>(null);
useEffect(() => {
setHiddenMessageIds(new Set());
setSelectedRetrievalResult(null);
}, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return;
setHiddenMessageIds((prev) => {
const next = new Set(prev);
for (const id of deletedIds) next.add(id);
return next;
});
}, []);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
});
}, []);
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
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
return override === undefined
? 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(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
@@ -312,6 +358,23 @@ export function MessageList({
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
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 [];
@@ -345,9 +408,7 @@ export function MessageList({
}
return new Set(lastMessageByRun.values());
}, [answerFeedbackContexts, messages]);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
const askTimes = useChatAskTimes();
// Stamp a wall-clock time onto every *question* the first time it is seen, in
// a single batched commit. Questions sent this session already carry an
@@ -473,10 +534,29 @@ 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 ?? [];
@@ -486,7 +566,7 @@ export function MessageList({
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
afterMessageId: previousHumanId,
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
@@ -497,6 +577,7 @@ export function MessageList({
referenceBatches,
referencesEnabled,
skillDisplayMap,
smartRetrievalAssistantMessageIds,
],
);
@@ -617,20 +698,14 @@ export function MessageList({
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-[var(--container-width-lg)]",
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{(() => {
let didRenderLiveKnowledgeRetrieval = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
const items = group.messages.map((msg) => {
@@ -808,15 +883,30 @@ export function MessageList({
{taskReportImportSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{businessCardSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
} else if (group.type === "assistant:business-card") {
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
const message = group.messages[0];
return businessCardSlot && message ? (
<div key={group.id} className="w-full pb-2">
{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) {
if (hasPresentFiles(message)) {
@@ -824,16 +914,9 @@ 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]) && (
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
@@ -963,15 +1046,95 @@ export function MessageList({
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
const processingBody = (
<div className="w-full">
<MessageGroup
threadId={threadId}
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
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}
/>
);
})}
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -985,12 +1148,27 @@ export function MessageList({
</div>
);
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
const leftoverRewriteItems = documentRewriteItems.filter(
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
);
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} />
))}
@@ -1012,6 +1190,16 @@ 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>
);
}
@@ -57,7 +57,7 @@ export function MultiRoundRagMessageList({
return (
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
<ConversationContent
className="mx-auto w-full max-w-[var(--container-width-lg)] gap-8 pt-10"
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
>
{messages.map((message, index) => {
const isLastAssistant =
@@ -24,7 +24,7 @@ function SkeletonBar({
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-[var(--container-width-lg)] flex-col gap-12 p-8 pt-16">
<div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
@@ -0,0 +1,406 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { Message } from "@langchain/langgraph-sdk";
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "started",
queries: ["成都世运会"],
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 3,
elapsed_ms: 420,
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "started",
pattern: "火炬|开幕|闭幕",
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 4,
elapsed_ms: 1380,
},
];
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading={false}
/>,
);
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
const messages = [
{
type: "ai",
id: "ai-pending-wiki",
content: "",
tool_calls: [
{
id: "wiki-pending",
name: "ordinary_wiki_search",
args: { pattern: "数字中国|数据要素" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /检索中/);
assert.match(html, /正在查 Wiki/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
});
test("labels and normalizes a visible model-authored regex", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[
{
type: "ai",
id: "ai-regex",
content: "",
tool_calls: [
{
id: "wiki-regex",
name: "ordinary_wiki_search",
args: { pattern: "^# [一-龥]{2,10}篇�" },
},
],
},
] as unknown as Message[]}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
assert.doesNotMatch(html, /一-龥|�/);
});
test("presents an interrupted historical action as neutral instead of failed", () => {
const messages = [
{
type: "ai",
id: "ai-interrupted-wiki",
content: "",
tool_calls: [
{
id: "wiki-interrupted",
name: "ordinary_wiki_search",
args: { pattern: "数字中国" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading={false}
/>,
);
assert.match(html, /检索已结束(部分步骤未完成)/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
});
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索中/);
assert.match(html, /成都世运会/);
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
});
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
roundOffset={2}
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-news",
slug: "news/opening",
title: "开幕新闻 Wiki",
preview: "页面摘要",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /data-retrieval-result-detail="true"/);
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
});
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_read_page",
status: "completed",
result_count: 1,
knowledge_base_count: 1,
coverage: [
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
],
elapsed_ms: 1260,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-digital-china",
knowledge_base: "数字中国政策目录智能检索远端验收",
slug: "digital-china/timeline",
page_ref: "wiki-page:v1:digital-china-timeline",
title: "数字中国建设时间线",
content_markdown:
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /读取结果/);
assert.match(html, /数字中国建设时间线/);
assert.match(html, /页面正文概览,包含建设目标/);
assert.match(html, /右侧查看/);
assert.match(html, /耗时 1\.3 秒/);
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
assert.doesNotMatch(html, /知识库知识库/);
assert.match(html, /data-retrieval-result-detail="true"/);
});
test("shows all usable document-read results without a generic partial warning", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "document_chunk_read",
status: "completed",
result_count: 20,
partial: true,
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
message: "原文上下文读取完成,找到 20 条结果",
result_items: Array.from({ length: 20 }, (_, index) => ({
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_base: "国内新闻",
knowledge_id: "document-long-news",
chunk_id: `chunk-${index + 1}`,
title: `长篇新闻第 ${index + 1} 段`,
content: `原文上下文第 ${index + 1} 段`,
})),
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
20,
);
assert.match(html, /长篇新闻第 1 段/);
assert.match(html, /长篇新闻第 20 段/);
assert.doesNotMatch(html, /部分数据源返回警告/);
assert.doesNotMatch(html, /部分完成/);
});
test("keeps historical results without stable ids as non-clickable summaries", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base: "旧知识库",
title: "旧检索结果",
},
],
},
]}
isLoading
/>,
);
assert.match(html, /data-retrieval-result-summary="true"/);
assert.match(html, /该历史检索结果缺少稳定详情标识/);
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
});
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
const firstFrame: KnowledgeRetrievalProgressFrame = {
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
retrieval_id: "raw-first",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-1",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
...firstFrame,
retrieval_id: "raw-second",
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-provider-remapped",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const firstHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[firstFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
const repeatedHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[repeatedFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
1,
);
assert.equal(
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -0,0 +1,525 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
AlertCircleIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CirclePauseIcon,
DatabaseIcon,
Loader2Icon,
PanelRightOpenIcon,
SearchCheckIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
buildSmartKnowledgeRetrievalRounds,
type KnowledgeRetrievalProgressFrame,
type SmartKnowledgeRetrievalAction,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { cn } from "@/lib/utils";
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.status === "failed") return "检索失败";
if (action.status === "interrupted") return "已中止";
if (action.status === "running") {
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
}
if (action.allResultsAlreadyShown) return "均为已展示结果";
const hasUsableResults =
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
const normalized = Math.max(0, elapsedMs);
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
if (normalized < 60_000) {
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
return `${seconds} 秒`;
}
const roundedSeconds = Math.round(normalized / 1_000);
const minutes = Math.floor(roundedSeconds / 60);
const seconds = roundedSeconds % 60;
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
}
function channelText(channel: string | undefined): string | null {
if (!channel) return null;
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
if (channel === "provider") return "知识库 Wiki";
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
return channel;
}
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
if (action.status === "running") {
return <Loader2Icon className="size-4 animate-spin text-primary" />;
}
if (action.status === "completed") {
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
}
if (action.status === "failed") {
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
}
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
}
function resultDetailTarget(
item: SmartKnowledgeRetrievalResultItem,
): WeKnoraSourceTarget | null {
if (!item.knowledgeBaseId) return null;
if (item.kind === "wiki" && item.wikiSlug) {
return {
knowledgeBaseId: item.knowledgeBaseId,
wikiSlug: item.wikiSlug,
tab: "wiki",
};
}
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
return {
knowledgeBaseId: item.knowledgeBaseId,
chunkId: item.chunkId,
documentId: item.documentId,
tab: "documents",
};
}
return null;
}
function RetrievalAction({
action,
onOpenResult,
}: {
action: SmartKnowledgeRetrievalAction;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
const channel = channelText(action.channel);
return (
<div
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
data-retrieval-operation={action.operation}
>
<div className="flex min-w-0 items-start gap-2.5">
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">{action.label}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs",
action.status === "running"
? "text-primary"
: action.status === "completed"
? "text-emerald-700 dark:text-emerald-300"
: action.status === "failed"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground",
)}
>
<ActionStatusIcon action={action} />
{actionStatusText(action)}
</span>
</div>
{action.queries.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted-foreground">查询</span>
{action.queries.map((query) => (
<span
key={query}
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
>
{query}
</span>
))}
</div>
)}
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof action.knowledgeBaseCount === "number" ? (
<span>
覆盖 {action.knowledgeBaseCount} 个知识库
{action.knowledgeBases.length > 0
? `:${action.knowledgeBases.join("、")}`
: ""}
</span>
) : action.knowledgeBases.length > 0 ? (
<span>知识库:{action.knowledgeBases.join("、")}</span>
) : null}
{typeof action.scannedPages === "number" && (
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
)}
{typeof action.scannedChunks === "number" && (
<span>扫描 {action.scannedChunks} 个原文片段</span>
)}
{action.kind === "read" && typeof action.total === "number" && (
<span>共 {action.total} 个片段</span>
)}
{channel && <span>通道:{channel}</span>}
{typeof action.elapsedMs === "number" && (
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
)}
</div>
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
<div className="mb-1.5 text-xs text-muted-foreground">
{action.kind === "read" ? "读取结果" : "查询结果"}
</div>
{action.kind === "read" ? (
<div className="space-y-2">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
const preview = item.preview?.trim().slice(0, 600);
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{item.title}
</span>
{target && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
右侧查看
<PanelRightOpenIcon className="size-3.5" />
</span>
)}
</div>
{preview && (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{preview}
</p>
)}
</>
);
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
{content}
</button>
) : (
<div
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
title="该历史检索结果缺少稳定详情标识"
>
{content}
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
<span className="truncate">{item.title}</span>
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
</button>
) : (
<span
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
title="该历史检索结果缺少稳定详情标识"
>
<span className="truncate">{item.title}</span>
</span>
);
})}
{(action.resultCount ?? 0) > action.resultItems.length && (
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
</span>
)}
</div>
)}
</div>
) : action.resultTitles.length > 0 ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
结果摘要:{action.resultTitles.join(";")}
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
</p>
) : null}
{action.message && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{action.message}
</p>
)}
{action.error && (
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
{action.error}
</p>
)}
{/* Provider warning codes stay in the structured trace for
diagnostics. The old generic warning was misleading both when
useful evidence existed and when the result set was empty. */}
</div>
</div>
</div>
);
}
export function SmartKnowledgeRetrievalTrace({
messages,
progressFrames = [],
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
onOpenResult,
}: {
messages: Message[];
progressFrames?: KnowledgeRetrievalProgressFrame[];
/**
* The whole current user-turn group. Interleaved rendering splits one
* retrieval invocation into one component, but result dedupe must still see
* every invocation in the turn.
*/
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const rounds = useMemo(
() => {
const localRounds = buildSmartKnowledgeRetrievalRounds(
messages,
progressFrames,
isLoading,
);
if (!dedupeScopeMessages) return localRounds;
const globallyDedupedActions = new Map(
buildSmartKnowledgeRetrievalRounds(
dedupeScopeMessages,
dedupeScopeProgressFrames ?? [],
isLoading,
)
.flatMap((round) => round.actions)
.map((action) => [action.id, action] as const),
);
return localRounds.map((round) => ({
...round,
actions: round.actions.map((action) => {
const globalAction = globallyDedupedActions.get(action.id);
return globalAction ? { ...globalAction, round: action.round } : action;
}),
}));
},
[
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
messages,
progressFrames,
],
);
const actions = rounds.flatMap((round) => round.actions);
// The chat can keep streaming its final answer after retrieval has already
// finished. Only an unfinished retrieval action owns the "检索中" state;
// the broader thread loading flag must not keep the spinner alive or block
// the collapse control during answer generation.
const isActive = actions.some((action) => action.status === "running");
const hasFailure = actions.some((action) => action.status === "failed");
const hasInterrupted = actions.some(
(action) => action.status === "interrupted",
);
const rawHits = actions
.filter((action) => action.surface === "raw" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const wikiHits = actions
.filter((action) => action.surface === "wiki" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const elapsedActions = actions.filter(
(action) => typeof action.elapsedMs === "number",
);
const cumulativeElapsedMs =
elapsedActions.length > 0
? elapsedActions.reduce(
(total, action) => total + (action.elapsedMs ?? 0),
0,
)
: undefined;
// A live trace opens so newly completed result cards remain visible while
// the final answer starts streaming. Replayed history starts collapsed.
const [isExpanded, setIsExpanded] = useState(
isLoading && actions.length > 0,
);
const wasActive = useRef(isActive);
const wasLoading = useRef(isLoading);
useEffect(() => {
if (!wasActive.current && isActive) {
setIsExpanded(true);
}
if (wasLoading.current && !isLoading) {
setIsExpanded(false);
}
wasActive.current = isActive;
wasLoading.current = isLoading;
}, [isActive, isLoading]);
// An in-flight retrieval always stays visible. A just-completed live trace
// remains open and user-collapsible while the answer streams; replayed
// history starts collapsed and can be reopened for inspection.
const isOpen = isActive || isExpanded;
if (actions.length === 0) return null;
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => {
if (!isActive) setIsExpanded(open);
}}
>
<section
aria-label="智能检索过程"
aria-live={isActive ? "polite" : undefined}
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
data-smart-knowledge-retrieval-trace
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{isActive ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SearchCheckIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
<div className="flex items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isActive
? "bg-primary/10 text-primary"
: hasFailure
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: hasInterrupted
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{isActive
? "检索中"
: hasFailure
? "已完成(部分步骤失败)"
: hasInterrupted
? "检索已结束(部分步骤未完成)"
: "检索完成"}
</span>
{!isActive && (
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
>
<span>{isOpen ? "收起" : "展开"}</span>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
</p>
)}
{isActive && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
</div>
</div>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</CollapsibleContent>
</section>
</Collapsible>
);
}
@@ -5,12 +5,15 @@ 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 { 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 "管理员";
@@ -57,14 +60,15 @@ export function AccountSettingsPage() {
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
window.location.assign(`${window.location.pathname}#/login`);
});
};
return (
<div className="space-y-8">
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
@@ -106,6 +110,8 @@ export function AccountSettingsPage() {
</div>
</div>
</SettingsSection>
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
</div>
);
}
@@ -23,7 +23,7 @@ import { toast } from "sonner";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { FONT_SCALE_MODE_OPTIONS, FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -43,7 +43,11 @@ import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useUpdateUserPreferences,
useUserPreferencesQuery,
} from "@/core/user-preferences";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useHtmlMapSettings,
useLayoutMode,
@@ -182,6 +186,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const { data: userPreferences, isPending: preferencesLoading } = useUserPreferencesQuery();
const updateUserPreferences = useUpdateUserPreferences();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
@@ -752,7 +758,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<SettingsSection
title="字号"
description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
>
<div className="space-y-3">
<Select
@@ -775,6 +781,29 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-2">
{FONT_SCALE_MODE_OPTIONS.map((item) => {
const selected = (settings.display.fontScaleMode ?? "text") === item.value;
return (
<Button
key={item.value}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => setSettings("display", { fontScaleMode: item.value })}
>
{item.label}
</Button>
);
})}
</div>
<p className="text-muted-foreground text-xs">
{
FONT_SCALE_MODE_OPTIONS.find(
(item) => item.value === (settings.display.fontScaleMode ?? "text"),
)?.description
}
</p>
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
</div>
@@ -836,6 +865,30 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
title="对话体验"
description="问答过程中的辅助显示。"
>
<div className="mb-4 flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<label htmlFor="followup-suggestions" className="text-sm font-medium">
问答后推荐问题
</label>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
默认关闭。开启后,在后续回答结束时生成并显示可继续提问的问题。仅对当前账号生效,设置随账号保存。
</p>
</div>
<Switch
id="followup-suggestions"
checked={userPreferences?.followup_suggestions_enabled ?? false}
disabled={preferencesLoading || updateUserPreferences.isPending}
onCheckedChange={(checked) => {
updateUserPreferences.mutate(
{ followup_suggestions_enabled: checked },
{
onError: (error) => toast.error(error.message || "保存失败"),
},
);
}}
aria-label="问答后推荐问题"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">回答速度指示器</div>
@@ -0,0 +1,102 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
import {
getAuthenticationSettings,
saveAuthenticationSettings,
type AuthenticationSettings,
} from "@/core/auth/account-settings-api";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { SettingsSection } from "./settings-section";
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const queryClient = useQueryClient();
const queryKey = ["authentication-settings", live.userId];
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
const settings = draft ?? query.data;
if (!isAdmin) return null;
const update = (key: keyof AuthenticationSettings, value: boolean) => {
if (!settings) return;
const next = { ...settings, [key]: value };
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
setDraft(next);
};
const save = async () => {
if (!settings || mutation.isPending) return;
try {
const saved = await mutation.mutateAsync(settings);
queryClient.setQueryData(queryKey, saved);
setDraft(null);
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
toast.success("登录与注册设置已保存,即时生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
return (
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
{query.isPending ? (
<p className="text-muted-foreground text-sm">正在加载设置…</p>
) : query.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{query.error.message}</p>
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
</div>
) : settings && (
<div className="space-y-5">
<div className="divide-y rounded-xl border px-5">
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
</div>
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
<div className="flex justify-end">
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
</div>
</div>
)}
</SettingsSection>
);
}
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
id: string;
title: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-5 py-5">
<div className="space-y-1">
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
</div>
);
}
@@ -0,0 +1,86 @@
"use client";
import { Loader2Icon, ShrinkIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
useUpdateConversationContextSettings,
} from "@/core/system-settings/hooks";
export function ContextWindowSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useConversationContextSettings();
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ recent_turn_window_enabled: checked });
toast.success(
checked
? `近 ${recentTurns} 轮上下文模式已开启`
: `近 ${recentTurns} 轮上下文模式已关闭`,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<ShrinkIcon className="size-5" />
上下文管理
</h3>
<p className="text-muted-foreground text-sm">
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
<p className="text-muted-foreground text-sm">
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
</p>
</section>
);
}
@@ -0,0 +1,120 @@
"use client";
import { Loader2Icon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useOrdinaryKnowledgeRetrievalSettings,
useUpdateOrdinaryKnowledgeRetrievalSettings,
} from "@/core/system-settings/hooks";
export function KnowledgeRetrievalSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useOrdinaryKnowledgeRetrievalSettings();
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
const enabled = data?.smart_reasoning_enabled ?? false;
const advancedManagementEnabled =
data?.advanced_management_enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ smart_reasoning_enabled: checked });
toast.success(
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleAdvancedManagementChange = async (checked: boolean) => {
try {
await update.mutateAsync({ advanced_management_enabled: checked });
toast.success(
checked ? "知识库高级管理入口已开放" : "知识库高级管理入口已隐藏",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<SearchIcon className="size-5" />
知识检索
</h3>
<p className="text-muted-foreground text-sm">
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">普通知识库智能推理检索</div>
<p className="text-muted-foreground text-sm">
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
输入框的检索模式菜单中切换到经典检索。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="普通知识库智能推理检索"
/>
)}
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">知识库高级管理入口</div>
<p className="text-muted-foreground text-sm">
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
基础知识库管理和查看 Wiki 索引始终可用。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={advancedManagementEnabled}
disabled={isError || update.isPending}
onCheckedChange={handleAdvancedManagementChange}
aria-label="知识库高级管理入口"
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
</p>
</section>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getStoredAuth, setStoredAuth } from "@/core/auth";
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
import { SettingsSection } from "./settings-section";
export function PasswordSettings({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["account-password", userId],
queryFn: getAccountPasswordStatus,
});
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const hasPassword = status.data?.has_password ?? true;
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pending || !status.data) return;
setError("");
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setError("新密码至少需要 8 位,请避免常见弱密码");
return;
}
const auth = getStoredAuth();
if (!auth || auth.user_id !== userId) {
setError("登录账号已变化,请重新打开个人信息");
return;
}
setPending(true);
try {
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
// Another tab may have changed the active account while the request was pending.
if (getStoredAuth()?.access_token !== auth.access_token) return;
if (session.access_token) {
setStoredAuth({
...auth,
access_token: session.access_token,
token_type: session.token_type ?? auth.token_type,
expires_in: session.expires_in ?? auth.expires_in,
needs_setup: false,
});
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "密码修改失败");
} finally {
setPending(false);
}
};
return (
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{status.error.message}</p>
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
</div>
) : (
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
{requiresCurrentPassword && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
</div>
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
</form>
)}
</SettingsSection>
);
}
@@ -1,6 +1,19 @@
"use client";
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UploadCloudIcon, UserIcon, WrenchIcon } from "lucide-react";
import {
BellIcon,
BrainIcon,
CpuIcon,
FileOutputIcon,
PaletteIcon,
SearchIcon,
ShieldCheckIcon,
ShrinkIcon,
SparklesIcon,
UploadCloudIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -12,19 +25,28 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
type SettingsSection =
| "account"
| "authentication"
| "appearance"
| "conversation-context"
| "knowledge-retrieval"
| "memory"
| "fileWriting"
| "uploads"
| "tools"
| "encoder"
@@ -37,6 +59,7 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -65,8 +88,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin
? [
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
@@ -131,17 +158,36 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "appearance" && (
<AppearanceSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "encoder" && isAdmin && (
<LocalEncoderSettingsCard />
)}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -0,0 +1,70 @@
"use client";
import { FileOutputIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useUpdateWriteFileCompatibilitySettings,
useWriteFileCompatibilitySettings,
} from "@/core/system-settings/hooks";
export function WriteFileCompatibilitySettingsPage() {
const { data, isLoading, isError } = useWriteFileCompatibilitySettings();
const update = useUpdateWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "文件流式兼容已开启" : "文件流式兼容已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<FileOutputIcon className="size-5" />
文件流式兼容
</h3>
<p className="text-muted-foreground text-sm">
临时兼容会缓存长工具参数的内网模型网关。开启后,所有兼容模型仍调用原有
write_file 工具,但文件正文改由普通文本流生成,再投影为标准 write_file
流式事件。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">所有模型启用兼容写入</div>
<p className="text-muted-foreground text-sm">
适用于当前配置的 OpenAI-compatible Chat Completions
模型。关闭后恢复各模型原有写入方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="所有模型启用文件流式兼容"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取文件流式兼容开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后无需重启后台,从下一次发起的问答开始生效;正在生成中的回答不会中途切换。
</p>
</section>
);
}
@@ -9,7 +9,10 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
@@ -56,6 +59,9 @@ export function SourceCard({
}) {
const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
null,
);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({
@@ -65,7 +71,13 @@ export function SourceCard({
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const isWikiIndexSource =
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource =
Boolean(previewTarget?.wikiSlug) ||
source.type === "llmwiki_wiki" ||
source.type === "llmwiki_wiki_page";
const matchedHeading = isWikiSource
? String(
(source.raw?.matched_heading as string | undefined) ||
@@ -78,7 +90,7 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
? source.raw.rank_score_semantics
: "";
@@ -155,7 +167,7 @@ export function SourceCard({
</p>
)}
</div>
{(source.url || isWikiSource) && (
{(source.url || isWikiSource || isWikiIndexSource) && (
previewTarget ? (
<span className={cn(
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
@@ -166,25 +178,39 @@ export function SourceCard({
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
{isWikiSource ? "Wiki 文档" : "知识库文档"}
</span>
) : isWikiIndexSource ? (
<span className="mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md bg-emerald-100 px-1.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenTextIcon className="size-3.5" />
Wiki 索引
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
{previewTarget && (
{(previewTarget || isWikiIndexSource) && (
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
<span>
{isWikiIndexSource
? "点击卡片查看完整 Wiki 索引"
: isWikiSource
? "点击卡片查看完整 Wiki"
: "点击卡片查看详情"}
</span>
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
)}
</div>
{previewTarget && (
{(previewTarget || linkedTarget) && (
<WeKnoraSourceDrawer
open={detailOpen}
target={previewTarget}
target={linkedTarget ?? previewTarget}
title={source.title}
quote={content}
onOpenChange={setDetailOpen}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) setLinkedTarget(null);
}}
/>
)}
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
@@ -246,6 +272,18 @@ export function SourceCard({
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
linkClassName={(href) =>
href.startsWith("/weknora-source-preview?")
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
: undefined
}
onLinkClick={(event, href) => {
const target = resolveWeKnoraSourceTarget({ url: href });
if (!target) return;
event.preventDefault();
setLinkedTarget(target);
setDetailOpen(true);
}}
/>
</article>
</>
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
writing_sources: "上传知识资料",
generating_wiki: "分析并生成分类 Wiki",
vectorizing_wiki: "Wiki 向量化",
syncing_assistant: "同步知识梳理总库",
syncing_assistant: "同步检索索引",
activating: "激活新版本",
completed: "完成",
};
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora"
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p>
)}
</div>
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
</div>
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
@@ -84,7 +84,7 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
baseTileLoaded = true;
setTileFailed(false);
};
const onError = (event: maplibregl.MapEventType["error"]) => {
const onError = (event: maplibregl.ErrorEvent) => {
const message = event.error?.message ?? "";
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
};
@@ -49,6 +49,30 @@ test("accepts a dedicated mindmap contract", () => {
assert.equal(result.ok && result.spec.type, "mindmap");
});
test("accepts four meaningful children under a non-root analysis node", () => {
const result = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
title: "人物分析",
rootId: "root",
nodes: [
{ id: "root", parentId: null, label: "人物分析", kind: "topic" },
{ id: "basic", parentId: "root", label: "基本信息", kind: "dimension" },
{ id: "identity", parentId: "basic", label: "身份", kind: "finding" },
{ id: "experience", parentId: "basic", label: "经历", kind: "finding" },
{ id: "relations", parentId: "basic", label: "关系", kind: "finding" },
{ id: "events", parentId: "basic", label: "事件", kind: "finding" },
],
}),
);
assert.equal(result.ok, true);
assert.equal(
result.ok && result.spec.type === "mindmap" && result.spec.nodes.length,
6,
);
});
test("renders a completed legacy mindmap fence as a mindmap", () => {
const content = [
"```mindmap",
@@ -105,6 +129,26 @@ test("rejects dense mindmaps before they can overload the canvas", () => {
);
assert.equal(tooManyChildren.ok, false);
const tooManyBranchChildren = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "basic", parentId: "root", label: "基本信息" },
...Array.from({ length: 5 }, (_, index) => ({
id: `detail-${index}`,
parentId: "basic",
label: `要点 ${index + 1}`,
})),
],
}),
);
assert.equal(tooManyBranchChildren.ok, false);
assert.equal(
tooManyBranchChildren.ok ? "" : tooManyBranchChildren.error,
"节点“基本信息”最多保留 4 个子节点,当前有 5 个;请合并同类信息",
);
const tooManyNodes = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
@@ -125,6 +169,27 @@ test("rejects dense mindmaps before they can overload the canvas", () => {
);
});
test("keeps the four-layer depth guard after branch tolerance", () => {
const tooDeep = parseVisualizationSpec(
"mindmap-json",
JSON.stringify({
nodes: [
{ id: "root", parentId: null, label: "主题" },
{ id: "level-1", parentId: "root", label: "一级" },
{ id: "level-2", parentId: "level-1", label: "二级" },
{ id: "level-3", parentId: "level-2", label: "三级" },
{ id: "level-4", parentId: "level-3", label: "四级" },
],
}),
);
assert.equal(tooDeep.ok, false);
assert.equal(
tooDeep.ok ? "" : tooDeep.error,
"思维导图最多支持 4 层层级",
);
});
test("renders a valid map contract from a generic json fence", () => {
const content = [
"下面是地图:",
@@ -83,6 +83,14 @@ export type MindmapSpec = z.infer<typeof mindmapSchema>;
type MindmapNode = MindmapSpec["nodes"][number];
export type VisualizationSpec = MapSpec | MindmapSpec;
const MINDMAP_ROOT_CHILD_LIMIT = 6;
// The generation prompt still asks the model to target three children so the
// overview stays compact. Real analysis dimensions can naturally contain four
// equally important facts, though, and rejecting the entire graph for that
// one-node overflow is too brittle. Keep a one-item rendering tolerance while
// the global node, depth and cycle guards continue to bound the canvas.
const MINDMAP_BRANCH_CHILD_LIMIT = 4;
export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
@@ -131,12 +139,15 @@ function validateMindmap(spec: MindmapSpec): string | null {
children.set(node.parentId, siblings);
}
for (const [parentId, siblings] of children) {
const limit = parentId === root.id ? 6 : 3;
const limit =
parentId === root.id
? MINDMAP_ROOT_CHILD_LIMIT
: MINDMAP_BRANCH_CHILD_LIMIT;
if (siblings.length > limit) {
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
return parentId === root.id
? `中心主题最多保留 6 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 3 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
? `中心主题最多保留 ${MINDMAP_ROOT_CHILD_LIMIT} 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
: `节点“${parentLabel}”最多保留 ${MINDMAP_BRANCH_CHILD_LIMIT} 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
}
}
@@ -23,7 +23,7 @@ import {
TimerIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import {
@@ -93,11 +93,34 @@ export function WorkspaceNavMenu() {
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const { settingsItems } = useWorkspaceCompactNavItems();
// 整页缩放时菜单会盖住按钮。按下后松手若落在菜单项上,Radix 会当成选中并立刻关闭。
// 这次打开手势的 pointerup / click 不传给菜单项。
const openingGesture = useRef(false);
useEffect(() => {
setMounted(true);
}, []);
const markOpeningGesture = () => {
openingGesture.current = true;
window.setTimeout(() => {
openingGesture.current = false;
}, 400);
};
const swallowOpeningPointerUp = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
if (!openingGesture.current) return;
event.preventDefault();
event.stopPropagation();
};
const swallowOpeningClick = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
if (!openingGesture.current) return;
openingGesture.current = false;
event.preventDefault();
event.stopPropagation();
};
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
const startCompactGuide = () => {
@@ -141,19 +164,23 @@ export function WorkspaceNavMenu() {
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
onPointerDown={markOpeningGesture}
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56 rounded-lg"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="top"
align="end"
sideOffset={4}
sideOffset={8}
onPointerUpCapture={swallowOpeningPointerUp}
onClickCapture={swallowOpeningClick}
>
<DropdownMenuGroup>
{settingsItems.map((item) => {
@@ -70,14 +70,14 @@ export function WorkspaceSidebar({
aria-hidden={!isSidebarOpen}
className={cn(
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
isSidebarOpen ? "w-[var(--sidebar-width)]" : "w-0 pointer-events-none",
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-[var(--sidebar-width)] border-r border-border",
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
+8 -3
View File
@@ -21,10 +21,12 @@ function requestUrl(input: RequestInfo | URL): string {
}
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
const headers = withAuthHeaders(init?.headers);
const requestedAuthorization = headers.get("Authorization");
const response = await fetch(input, {
...init,
credentials: init?.credentials ?? "include",
headers: withAuthHeaders(init?.headers),
headers,
});
// Session is considered lost ONLY when the backend actually answers 401.
@@ -35,13 +37,16 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
if (response.status === 401) {
const url = requestUrl(input);
const isAuthEndpoint = url.includes("/api/v1/auth/");
if (!isAuthEndpoint && getStoredAuth()) {
// An older in-flight request may finish after a successful login/password
// change. Its 401 must not erase the newly issued session.
if (!isAuthEndpoint && getStoredAuth() && requestedAuthorization
&& requestedAuthorization === getAuthorizationHeaderValue()) {
clearStoredAuth();
if (
typeof window !== "undefined" &&
!window.location.hash.startsWith("#/login")
) {
window.location.replace("#/login");
window.location.replace("#/login?reason=session-expired");
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
export interface AuthenticationOptions {
password_login_enabled: boolean;
registration_enabled: boolean;
sso_enabled: boolean;
single_session_enabled?: boolean;
needs_setup?: boolean;
}
function accountError(body: unknown, fallback: string): string {
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map((item) => item?.msg).filter(Boolean).join(";") || fallback;
}
if (detail && typeof detail === "object") {
const value = detail as { message?: string; msg?: string };
return value.message || value.msg || fallback;
}
return fallback;
}
/** Bound unauthenticated requests so an unreachable gateway offers a retry. */
async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
...init,
signal: controller.signal,
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(accountError(body, "请求失败,请稍后重试"));
return body as T;
} catch (error) {
if (controller.signal.aborted) throw new Error("连接超时,请检查网络后重试");
if (error instanceof TypeError) throw new Error("无法连接服务器,请检查网络后重试");
throw error;
} finally {
clearTimeout(timer);
}
}
export async function fetchAuthenticationOptions(): Promise<AuthenticationOptions> {
const options = await accountRequest<AuthenticationOptions>("/options", { cache: "no-store" });
if (!options || [options.password_login_enabled, options.registration_enabled, options.sso_enabled]
.some((value) => typeof value !== "boolean")) {
throw new Error("无法读取登录设置,请联系管理员或重试");
}
return options;
}
export function loginWithPassword(username: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest("/login/local", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username, password }),
});
}
export function registerAccount(username: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest("/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
}
export function initializeAdministrator(email: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest<LoginByUsernameResponse>("/initialize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
}
@@ -0,0 +1,60 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface AuthenticationSettings {
password_login_enabled: boolean;
registration_enabled: boolean;
sso_enabled: boolean;
single_session_enabled: boolean;
}
export interface PasswordSession {
access_token?: string;
token_type?: string;
expires_in?: number;
}
export interface AccountPasswordStatus {
id: string;
has_password: boolean;
requires_current_password: boolean;
uses_default_password: boolean;
}
async function readResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) {
const detail = data?.detail;
const message = typeof detail === "string"
? detail
: Array.isArray(detail)
? detail.map((item: { msg?: string }) => item.msg).filter(Boolean).join(";")
: detail?.message;
throw new Error(message || `请求失败(${response.status})`);
}
return data as T;
}
export async function getAuthenticationSettings(): Promise<AuthenticationSettings> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`));
}
export async function saveAuthenticationSettings(settings: AuthenticationSettings): Promise<AuthenticationSettings> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
}));
}
export async function getAccountPasswordStatus(): Promise<AccountPasswordStatus> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/me`));
}
export async function changeAccountPassword(currentPassword: string, newPassword: string): Promise<PasswordSession> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/change-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}));
}
+9 -7
View File
@@ -14,11 +14,16 @@ export interface UserListItem {
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
/**
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
* `…/login/<用户名>?password=xxxx` 发给用户。
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
*/
login_password: string | null;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
@@ -42,10 +47,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
}
/**
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
*
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
+1 -1
View File
@@ -29,7 +29,7 @@ export function useSetUserApproval() {
});
}
/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */
/** 设置 / 重置账号的统一密码与登录口令;操作日志不记录密码。 */
export function useSetUserLoginPassword() {
const queryClient = useQueryClient();
return useMutation({
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
test("password entry never starts a guest login; explicit SSO still works", () => {
assert.equal(shouldStartSso(options, false, false), false);
assert.equal(shouldStartSso(options, true, false), true);
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, false, false), true);
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), true);
});
test("expired sessions wait for an explicit re-login instead of repeatedly kicking the other client", () => {
const legacy = { ...options, password_login_enabled: false };
assert.equal(shouldStartSso(legacy, false, false, true), false);
assert.equal(shouldStartSso(options, true, false, true), false);
assert.equal(shouldStartSso(legacy, false, false, false), true);
});
test("external login credentials before the hash are read, and route values take precedence", () => {
const params = mergeLoginSearch("?authToken=current&password=&taskId=42", "?authToken=stale&password=old&ticket=t&theme=dark-blue");
assert.equal(params.get("authToken"), "current");
assert.equal(params.get("password"), "");
assert.equal(params.get("ticket"), "t");
assert.equal(params.get("theme"), "dark-blue");
assert.equal(params.get("taskId"), "42");
assert.equal(mergeLoginSearch("", "?authToken=outer").get("authToken"), "outer");
});
test("stored tickets cannot override explicit login links or password entry", () => {
assert.equal(shouldUseStoredTicket(options, false), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, true), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false), true);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
assert.equal(shouldUseStoredTicket(null, false), false);
});
test("missing configuration, disabled SSO and registration never start SSO", () => {
assert.equal(shouldStartSso(null, true, false), false);
assert.equal(shouldStartSso({ ...options, sso_enabled: false }, true, false), false);
assert.equal(shouldStartSso(options, true, true), false);
});
test("switching forms retains the task destination and presentation without credentials", () => {
const search = accountPageSearch("?taskId=42&goPath=agentfx&embed=1&theme=dark-blue&password=secret&authToken=token&ticket=ticket&userId=old&yUserId=old&token=token&access_token=token&routeKey=task");
const params = new URLSearchParams(search);
assert.deepEqual([...params.keys()], ["taskId", "goPath", "embed", "theme"]);
assert.equal(params.get("taskId"), "42");
assert.equal(params.get("theme"), "dark-blue");
});
test("switching forms preserves a resolved route-key destination and empty search", () => {
assert.equal(accountPageSearch(""), "");
const params = new URLSearchParams(accountPageSearch("routeKey=task&embed=1", "/page/workspace/chats/new"));
assert.equal(params.get("redirect"), "/page/workspace/chats/new");
assert.equal(params.has("routeKey"), false);
assert.equal(params.get("embed"), "1");
});
+42
View File
@@ -0,0 +1,42 @@
import type { AuthenticationOptions } from "./account-api";
/** A missing options response must never fall back to a legacy guest login. */
export function shouldStartSso(
options: AuthenticationOptions | null,
explicitSso: boolean,
registering: boolean,
sessionExpired = false,
): boolean {
return Boolean(options?.sso_enabled && !registering && !sessionExpired &&
(explicitSso || !options.password_login_enabled));
}
/** External callers put login parameters before or after the hash route. */
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
const params = new URLSearchParams(outerSearch);
// A route parameter wins even when explicitly empty: do not revive an old
// outer credential when the current route has replaced it.
new URLSearchParams(routeSearch).forEach((value, key) => params.set(key, value));
return params;
}
export const LOGIN_CREDENTIAL_PARAMS = [
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
] as const;
/** Cached tickets belong only to the legacy, bare entry with login disabled. */
export function shouldUseStoredTicket(
options: AuthenticationOptions | null,
explicitSso: boolean,
): boolean {
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
}
/** Keep the destination/presentation when switching forms; discard credentials. */
export function accountPageSearch(search: string, routeKeyTarget?: string): string {
const params = new URLSearchParams(search);
if (routeKeyTarget) params.set("redirect", routeKeyTarget);
for (const key of LOGIN_CREDENTIAL_PARAMS) params.delete(key);
const result = params.toString();
return result ? `?${result}` : "";
}
@@ -0,0 +1,134 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { getBackendBaseURL } from "@/core/config";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
import {
AUTH_STATE_CHANGED_EVENT,
clearStoredAuth,
getStoredAuth,
setStoredAuth,
} from "./index";
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
| Extract<AccountAccess, { status: "unauthorized" }>
| { status: "checking" } | { status: "error"; message: string };
/** Keep protected pages unmounted until the configured authentication check succeeds. */
export function RequireAccount({ children, allowLegacyAnonymous = false }: {
children: ReactNode;
allowLegacyAnonymous?: boolean;
}) {
const { pathname, search } = useLocation();
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: "checking" });
const writingVerifiedAccount = useRef(false);
const requestInFlight = useRef(false);
useEffect(() => {
const refresh = () => {
if (!writingVerifiedAccount.current) setAttempt((value) => value + 1);
};
const onStorage = (event: StorageEvent) => {
if (!isEmbedActive() && !isIsolatedSession()
&& (event.key === "deerflow.auth" || event.key === null)) refresh();
};
const recheck = () => { if (!requestInFlight.current) refresh(); };
const onVisibility = () => { if (document.visibilityState === "visible") recheck(); };
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
window.addEventListener("storage", onStorage);
window.addEventListener("focus", recheck);
document.addEventListener("visibilitychange", onVisibility);
// A different browser can revoke this account's session. Also refresh the
// policy so enabling login or single-session mode takes effect in open tabs.
const timer = setInterval(onVisibility, 15_000);
return () => {
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
window.removeEventListener("storage", onStorage);
window.removeEventListener("focus", recheck);
document.removeEventListener("visibilitychange", onVisibility);
clearInterval(timer);
};
}, []);
useEffect(() => {
const controller = new AbortController();
const requestedAuth = getStoredAuth();
let disposed = false;
requestInFlight.current = true;
const timeout = setTimeout(() => controller.abort(), 20_000);
// A background recheck must not unmount an active editor on every poll.
// A changed account/token still blocks immediately until its own check ends.
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
? previous : { status: "checking" });
void checkAccountAccess({
auth: requestedAuth,
baseURL: getBackendBaseURL(),
signal: controller.signal,
allowLegacyAnonymous,
}).then((result) => {
if (disposed) return;
if (!isSameAccountSession(getStoredAuth(), requestedAuth)) {
setAttempt((value) => value + 1);
return;
}
writingVerifiedAccount.current = true;
try {
if (result.status === "unauthorized") {
if (requestedAuth) clearStoredAuth();
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
setStoredAuth(result.auth);
}
} finally {
writingVerifiedAccount.current = false;
}
setState(result);
}).catch((error: unknown) => {
if (disposed) return;
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
&& isSameAccountSession(getStoredAuth(), requestedAuth)
? { ...previous, warning: message } : { status: "error", message });
}).finally(() => {
clearTimeout(timeout);
if (!disposed) requestInFlight.current = false;
});
return () => {
disposed = true;
requestInFlight.current = false;
controller.abort();
clearTimeout(timeout);
};
}, [attempt, allowLegacyAnonymous]);
if (state.status === "unauthorized") {
const params = new URLSearchParams({ redirect: `${pathname}${search}` });
if (state.reason) params.set("reason", state.reason);
return <Navigate to={`/login?${params.toString()}`} replace />;
}
if (state.status === "error") {
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<p role="alert" className="text-sm text-muted-foreground">{state.message}</p>
<button type="button" className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
</div>;
}
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(getStoredAuth())) {
return <div className="contents">
{children}
{state.warning && <div role="alert" className="fixed right-4 bottom-4 z-50 flex max-w-md items-center gap-3 rounded-md border bg-background p-4 text-sm shadow-lg">
<span>{state.warning}</span>
<button type="button" className="shrink-0 text-primary underline" onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
</div>}
</div>;
}
return <div role="status" className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
正在验证登录状态…
</div>;
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
const auth = {
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
user_id: "user-1", email: "claimed@example.com", system_role: "admin", needs_setup: true, created: false,
};
const user = { id: "user-1", email: "actual@example.com", system_role: "user", needs_setup: false };
function server(enabled: boolean, me: Response = Response.json(user), singleSession = false) {
const calls: { url: string; init?: RequestInit }[] = [];
const fetcher: typeof fetch = async (input, init) => {
calls.push({ url: String(input), init });
return String(input).endsWith("/options")
? Response.json({ password_login_enabled: enabled, single_session_enabled: singleSession }) : me;
};
return { calls, fetcher };
}
test("enabled login rejects anonymous accounts including standalone mode before /me", async () => {
const { fetcher, calls } = server(true);
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "unauthorized" });
assert.equal(calls.length, 1);
});
test("a cached token never grants access before /me validates it", async () => {
const { fetcher, calls } = server(true, new Response(null, { status: 401 }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "https://gateway", fetcher }), { status: "unauthorized", reason: "session-expired" });
assert.equal(calls[1]?.url, "https://gateway/api/v1/auth/me");
assert.equal(new Headers(calls[1]?.init?.headers).get("Authorization"), "Bearer browser-token");
assert.equal(calls[1]?.init?.cache, "no-store");
});
test("verified server permissions replace claimed local permissions", async () => {
const { fetcher } = server(true);
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "allowed", auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: false },
});
});
test("a token for another account cannot authenticate the cached account", async () => {
const { fetcher } = server(true, Response.json({ ...user, id: "another-user" }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "unauthorized", reason: "session-expired" });
});
test("disabled login preserves legacy sessions and the standalone anonymous entry", async () => {
const { fetcher, calls } = server(false);
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "allowed", auth });
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
assert.ok(calls.every(({ url }) => url.endsWith("/options")));
});
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
const { fetcher } = server(true, new Response(null, { status: 503 }));
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher }), /无法验证登录/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /无法读取登录设置/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => { throw new TypeError("offline"); } }), /offline/);
});
test("single-session mode detects revoked sessions even with the password page disabled", async () => {
const { fetcher, calls } = server(false, new Response(null, { status: 401 }), true);
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "unauthorized", reason: "session-expired",
});
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
});
test("late failures cannot clear a renewed token or a different account", () => {
assert.equal(isSameAccountSession({ ...auth }, auth), true);
assert.equal(isSameAccountSession({ ...auth, access_token: "renewed" }, auth), false);
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
assert.equal(isSameAccountSession(null, auth), false);
});
@@ -0,0 +1,71 @@
import type { LoginByUsernameResponse } from "./index";
type StoredAccount = LoginByUsernameResponse | null;
export type AccountAccess =
| { status: "allowed"; auth: StoredAccount }
| { status: "unauthorized"; reason?: "session-expired" };
/** Compare credentials before applying a response from an earlier request. */
export function isSameAccountSession(current: StoredAccount, requested: StoredAccount): boolean {
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
}
export function hasAccountSession(auth: StoredAccount): auth is LoginByUsernameResponse {
return Boolean(typeof auth?.user_id === "string" && auth.user_id.trim()
&& typeof auth.access_token === "string" && auth.access_token.trim());
}
/** The server owns both the login policy and the account's current permissions. */
export async function checkAccountAccess({
auth,
baseURL,
signal,
allowLegacyAnonymous = false,
fetcher = fetch,
}: {
auth: StoredAccount;
baseURL: string;
signal?: AbortSignal;
allowLegacyAnonymous?: boolean;
fetcher?: typeof fetch;
}): Promise<AccountAccess> {
const optionsResponse = await fetcher(`${baseURL}/api/v1/auth/options`, {
credentials: "include", cache: "no-store", signal,
});
if (!optionsResponse.ok) throw new Error("无法读取登录设置,请稍后重试");
const options: unknown = await optionsResponse.json();
if (!options || typeof options !== "object"
|| !("password_login_enabled" in options)
|| typeof options.password_login_enabled !== "boolean") {
throw new Error("无法读取登录设置,请稍后重试");
}
const singleSessionEnabled = "single_session_enabled" in options && options.single_session_enabled === true;
if (!options.password_login_enabled && !singleSessionEnabled) {
return allowLegacyAnonymous || hasAccountSession(auth)
? { status: "allowed", auth }
: { status: "unauthorized" };
}
if (!hasAccountSession(auth)) return { status: "unauthorized" };
const response = await fetcher(`${baseURL}/api/v1/auth/me`, {
credentials: "include",
cache: "no-store",
headers: { Authorization: `Bearer ${auth.access_token}` },
signal,
});
if (response.status === 401) return { status: "unauthorized", reason: "session-expired" };
if (!response.ok) throw new Error("暂时无法验证登录状态,请稍后重试");
const user = await response.json() as Record<string, unknown> | null;
if (!user || typeof user.id !== "string" || typeof user.email !== "string"
|| typeof user.system_role !== "string" || typeof user.needs_setup !== "boolean") {
throw new Error("服务器返回的登录信息不完整,请稍后重试");
}
if (user.id !== auth.user_id) return { status: "unauthorized", reason: "session-expired" };
return {
status: "allowed",
auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: user.needs_setup },
};
}
+3 -1
View File
@@ -58,6 +58,7 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
export interface ParsedGoPath {
businessCode: string;
goPath: "3qfx" | "rwfx" | "xdfx";
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
roundtable: boolean;
}
@@ -70,7 +71,7 @@ export function parseGoPath(goPath: string): ParsedGoPath | null {
const base = roundtable ? raw.slice(0, -2) : raw;
const businessCode = GOPATH_BUSINESS_CODE[base];
if (!businessCode) return null;
return { businessCode, roundtable };
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
}
function consumerBaseUrl(): string {
@@ -381,6 +382,7 @@ export async function resolveTaskDeeplinkTarget(
openingText: buildOpeningText(detail, taskId, actionInfo),
actionId,
partitionByAction: isXdfx,
goPath: parsed.goPath,
});
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { taskThreadMetadata } from "./task-purpose";
test("3qfx and rwfx histories use the resolved task id", () => {
assert.deepEqual(
taskThreadMetadata(
{
taskId: "108",
agentId: "agent-1",
openingText: "",
},
"rwfx",
),
{
taskId: "108",
taskRecordId: "108",
taskGoPath: "rwfx",
thread_type: "task_deeplink",
},
);
});
test("xdfx history uses the original action id while preserving task id", () => {
assert.deepEqual(
taskThreadMetadata(
{
taskId: "108",
agentId: "agent-2",
openingText: "",
actionId: "action-9",
partitionByAction: true,
},
"xdfx",
),
{
taskId: "108",
actionId: "action-9",
taskRecordId: "action-9",
taskGoPath: "xdfx",
thread_type: "task_deeplink",
},
);
});
Loaded 100 of 440 files, more files were not shown because too many files have changed in this diff. Show more