Compare commits

..
605 changed files with 38804 additions and 19361 deletions

No files matched your search

+10 -3
View File
@@ -22,7 +22,9 @@ frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
frontend-web/vite-dev.err.log
frontend-web/.rspack-cache/
frontend-web/dist-firefox52/
# Frontend local packages/archives/docs (non-source)
frontend-web/pnpm-lock.yaml
@@ -96,6 +98,8 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/**
# Keep startup seed completeness and task-report conversion regressions versioned.
!offline-backend-20260512/backend/tests/test_seed_assets.py
!offline-backend-20260512/backend/tests/test_latency_diagnosis_seed.py
!offline-backend-20260512/backend/tests/test_query_slowdown_diagnosis_seed.py
!offline-backend-20260512/backend/tests/test_task_report_convert.py
!offline-backend-20260512/backend/tests/test_migration_compatibility.py
# Keep project-scoped cross-conversation memory regressions versioned.
@@ -238,5 +242,8 @@ offline-backend-20260512/backend/uv.lock
# Python bytecode cache
__pycache__/
# misc
.DS_Store
# misc
.DS_Store
# Local Firefox 52 test profiles
.firefox52-profile*/
+1 -2
View File
@@ -100,8 +100,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **按钮管理 (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`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v3 + shadcn-style components in `src/components/ui/`
- **麒麟 Chromium ~80 兼容 (Kylin legacy Chrome)**: Tailwind 自 v4 降级到 v3(v4 的 `@layer` 需要 Chrome 99+,麒麟系统自带 Chromium 80 多整站裸奔)。要点:① `tailwind.config.ts` —— 语义色走六套主题(`:root/.dark/.lightblue/.lightgreen/.iceblue/.lightyellow`)维护的 `--legacy-*-rgb` 通道变量,透明度修饰符(`bg-primary/10`)用 `rgb(var()/<alpha-value>)` 原生支持;border/input/sidebar-border/ring 用 `--*-alpha` 变量还原 v4 color-mix 叠乘语义;`xs/sm` 刻度(shadow/blur/rounded)与裸 `ring`(1px+currentColor)按 v4 值覆盖,类名零改动;streamdown 运行时拼类名的白名单在 `safelist`;v4 独有工具类(outline-hidden/text-shadow-sm/field-sizing-*)以插件手写兜底。② `darkMode: "class"` 输出 `:is(.dark *)`,由 `scripts/postcss-dark-descendant.mjs` 在 postcss 阶段改写为 `.dark X` 后代形式(v3.4 的 `["selector", ...]` 堆叠变体会生成非法选择器、无 darkMode 配置会退化成媒体查询,均不可用)。③ 手写 CSS 禁用 `:is()`(复杂参数 lightningcss 无法安全降级,globals/roundtable/intent 已全部展开为逗号选择器);`:has()`(105+)按渐进增强放行,唯一功能性依赖(input-group 焦点/校验环)在 globals.css 有 `@supports not selector(:has(*))` 回退。④ vite `build.target` + lightningcss targets = chrome80(转译 `:is()`/逻辑属性/媒体 range 语法、厂商前缀);`src/polyfills.ts`(core-js 精选 API)在 main.tsx 首位引入。⑤ 构建后跑 `pnpm check:legacy-css` 防回归(产物不得含 @layer/@property/:is()/未守护 color-mix)。
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
+40 -1
View File
@@ -1,5 +1,40 @@
# CLAUDE.md
## Retrieved files in chat (2026-10-07)
The shared `MessageGroup` renders `RetrievedFileList` below its reasoning/tool
timeline, so retrieved originals remain visible when old tool steps collapse and
do not depend on a model calling `present_files` or enabling reference mode.
The file list starts collapsed, keeps the latest retrieved file visible with the
total count, and expands the remaining files in reverse retrieval order.
`core/messages/retrieved-files.ts` derives these cards only from completed skill
invocations (a known skill tool name or a skill script path in an execution tool).
Generic reads, listings, report generation tools, uploads and outputs are excluded;
workspace paths require a structured retrieval/file container and exclude paths
written in the same message group. It accepts structured file links and WeKnora identifiers such as `knowledge_id` and
`document_file_name`. It handles JSON wrappers, MCP text blocks and bounded JSON
following console logs, deduplicates by file identity and caps one group at 100
files / 2,000 visited payload nodes. Do not extract file cards from assistant prose,
failed tool calls, arbitrary document text or `/mnt/skills` / host filesystem paths.
The admin-only Settings → Appearance switch「技能检索文件展示」persists globally as
`citation_display.retrieved_files_enabled` (default true). The existing citation-display
PUT endpoint enforces administrator authorization. The UI waits for the setting
before collecting files, shares its query cache, and refreshes active clients every
60 seconds/on stale window focus. Disabling it hides retrieved cards, not normal artifacts.
Thread files reuse the authorized artifact endpoints and uploaded-file reader;
knowledge files reuse the authorized LLMWiki original-file API and
`WeKnoraSourceDrawer`. `/api/llmwiki/sources/resolve` maps provider knowledge-base
ids to local mapping ids only after checking the viewer's existing read access.
External links use HTTP(S) only, credential-free fetches for
inline preview, and a native original-file link for download/fallback; never send
the DeerFlow bearer token to them. External download behavior depends on the
source's response headers and browser cross-origin rules. Unknown formats or
unavailable Office sidecars fall back to downloading the original. Public shares
use their existing file route and do not query private knowledge document APIs.
Closing the shared preview dialog aborts in-flight requests and releases blob URLs.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
@@ -82,7 +117,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是地图网页模式读取的瓦片模板。设置 → 外观的管理员选项「HTML 地图」可选 `echarts`(本地 GeoJSON,`data-df-geo`,默认)或 `leaflet`(地图网页,`data-df-map`);运行时同时保留两种初始化,已生成页面不因切换失效。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
@@ -106,6 +141,10 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
### Configuration
普通问答与智能体问答的 `write_file` 流式展示:`namedStreamingWriteFilePath` 统一判断路径是否可用;缺少路径、路径尚未完整或只有占位名时,不生成虚拟文件来自动打开沙箱,而由 `message-group.tsx` 在步骤内用绿色小字展示正文。步骤参数合并 `tool_calls` 与 `tool_call_chunks`,后续收到真实路径时复用原工具调用 ID 恢复正常文件预览;`outputs` 下真实文件的自动打开和沙箱流式预览保持原流程。
沙箱文件正在生成时,ArtifactsProvider 的 `select` / `openStreamedArtifact` 阻止切到其他文件;手动操作提示「当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。」,自动选择静默跳过。ChatBox 按选中文件的未完成 write_file 和运行状态注册锁,预览组件同时覆盖流式写入及全文改写;完成、停止或卸载时移除所属锁。同一个文件的虚拟预览和真实路径视为同一目标。
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
## Detailed Backend Reference
@@ -0,0 +1,105 @@
# 本地问答、资料库与技能并发验收
测试日期:2026-09-28 至 2026-09-29 凌晨。环境:Windows、约 8GB 物理内存、Python 3.12、Node 22、Gateway 单进程、SQLite、默认 MiniMax-M2.5。前端 Vite 5174,Gateway 8001。模型并发限流与 Redis 分布式门控未启用。
本次保留并验收了工作区原有的资料库索引、问答收尾、沙箱超时和流式恢复修改,另外修复了压测复现的技能刷新重复校验,以及真实地图问答暴露的内部待办提醒显示问题。测试结果不能等同于 Linux 多进程生产环境容量。
## 版本范围
开始测试时本地为 `3a07c476`,没有 `cognitive-situation-map`。随后拉取远程,发现 `origin/master` 已前进至 `3107f2cc`,包含该技能的初始化及 v2.6.0 精简运行时。保留本地修改后合入远程,合并提交为 `c9effe83`;最终绘图与多用户测试以合入后的代码为准。
旧本地版本里 `page-report-generation` 的地图仅支持中、美、日、台,因此其“不支持菲律宾”的反馈不能用于评价最新的 `cognitive-situation-map`。
## 真实问答
使用独立普通测试账户,阶梯间顺序运行,逐条检查预期测试关键词与结果、SSE `end`、持久化 `success`,结束后删除本轮生成的测试线程。1/3/5/10 路检查正文包含“测试”和数字 4;20 路增强为检查“测试正常”及明确的“2加2等于4”。这属于简单问答验收,不是复杂内容质量评测。
| 同时问答用户数 | 成功 | 首段答案中位耗时 | 流结束中位耗时 | 流结束最慢 |
|---|---:|---:|---:|---:|
| 1 | 1/1 | 5.792s | 6.439s | 6.439s |
| 3 | 3/3 | 2.214s | 3.245s | 3.287s |
| 5 | 5/5 | 2.885s | 3.702s | 4.373s |
| 10 | 10/10 | 2.920s | 4.796s | 5.416s |
| 20 | 20/20 | 4.364s | 10.622s | 11.135s |
累计 39/39 通过。20 用户整批耗时 11.829s,同期 health 12/12 成功、中位 13ms、最大 371ms。这里是一次阶梯短时突发,并未证明百用户持续长任务容量。
取消接口返回 204,58ms 后状态为 `interrupted`,测试线程删除返回 200。同期健康探测 20 次均成功,中位 9ms,最大 3.044s;说明仍存在数秒调度尖峰,不能仅凭平均值宣布无阻塞。
早期多个磁盘基准同时运行时,单路首段答案为 33.853s,日志显示同步 agent 构建 28.344s,health 也有 5s 超时。这批明确有本机资源争用,不纳入稳定容量结论。开发脚本启动的服务进程还曾消失,没有拿到退出或 reload 日志,原因未确认;期间的连接失败剔除。后续使用无热重载的固定进程复测。
## 资料库和知识库
- 资料库/知识库相关回归 186 项通过;资料库新存储 Memory/SQLite 专项另有 8 项通过,覆盖用户隔离、分页、原子换代、并发租约和旧任务围栏。
- 真实测试账户看到 2 个共享知识库;选中的库包含 668 份文档、168 个 Wiki 页面。首次 hybrid 查询 3.58s,返回 5 条。
- 排除合成磁盘负载后,空资料库、知识库目录和 hybrid 查询分别测试 1/4/8 并发,每档 8 次,总计 72/72 HTTP 200,零异常。
| 接口 | 8 并发中位耗时 | 8 并发最大耗时 |
|---|---:|---:|
| 资料库列表(空测试账户) | 165ms | 224ms |
| 知识库目录 | 517ms | 557ms |
| hybrid 检索(相同问题、5条结果) | 426ms | 441ms |
这组重复使用同账号和查询,包含热缓存效果;空账户列表不代表有大量文件的资料库。独立测试 SQLite 另生成了 101 个合成用户、10 万条资料元数据,480 次查询全部成功;其中单用户 5 万条资料关键词查询 P95 为 1.08s、首页 P95 为 130ms,但此轮同时有其他 IO 测试,只作为规模观察值。
资料库索引按 5 分钟过期刷新;进程异常退出时正在同步的租约最多等待 30 分钟恢复。大库关键词查询仍使用多字段包含匹配,不能据当前测试证明生产规模检索始终快速。
## 大技能与并发刷新
文件体积大和小文件数量多分别测试,使用隔离临时目录,不改真实技能内容。
| 场景 | 结果 |
|---|---|
| 真实技能副本,269 文件 / 2.75MiB,稳定缓存 64 并发 | 全成功,约 1.05s;早期混合负载观察值 |
| 同副本改 1 个资产,32 并发,修复前混合负载 | 27 成功、5 个 30s 锁等待超时,复现问题 |
| 独占配对:同副本改 1 个资产,32 并发,旧逻辑 | 32/32 成功,P95 13.700s,最大 14.842s |
| 独占配对:同场景,新逻辑 | 32/32 成功,P95 / 最大 3.963s,P95 降低约 71% |
| 128MiB 资产、34 文件,新逻辑 | 冷初始化 5.54s;变更后 32 并发 P95 0.584s,全成功 |
| 约 160 技能、5120 个小文件(修复前规模观察) | 冷加载执行者约 107s,31/32 等待者超时;热校验约 18s |
修复让同一进程内、相同权限配置的一批重叠请求共享一次刷新及最终校验。等待者仍检查当前缓存身份、权限哈希和能力标记;失败或技能下线时不返回旧视图。新增 8 项回归连同原有 62 项,共 70 项通过。
**剩余风险:** 数千个小文件的单次 Windows 元数据扫描仍昂贵,本次修复没有消除这项成本,也未证明跨进程刷新能合并。不能把 32/64 个技能调用线程等同于 32/64 个真实模型用户。
实际调用 `gongwen-assistant` 读取技能并输出短通知,10.672s 内正常结束,无工具卡住。但答案把要求的 10月8日改成10月15日:通信/技能读取通过,内容日期遵循失败,不能记为整体语义验收通过。
## 绘图与界面
普通问答已实际生成菲律宾地图底图及马尼拉、吕宋北部、巴拉望、棉兰老四个点位,点位弹窗可打开。使用明确标注的虚构数据,未进行时事真实性评估。初次模型额外要求确认消息内地图,回复确认后完成,属于可见的额外交互成本。
这次真实对话发现 `todo_completion_reminder` 被当作用户问题展示。修复后,后端待办提醒带 `hide_from_ui`,前端也按两个历史内部名字过滤聊天和问题导航;真实用户引用 `<system_reminder>` 的文本仍保留。后端 3 项、前端新及关联分组 12 项通过。
最新 `cognitive-situation-map v2.6.0` 已经通过真实问答完整调用:读取技能和菲律宾参考 JSON、写入场景、调用技能自带 `node render.mjs`、检查文件、`present_files` 交付,42.481s 完成,SSE 正常结束并保存 `success`。产物标题为“菲律宾冲突认知态势图(功能验收示意)”,包含 4 个事件、4 个标注、3 个行为体和 2 条连线,明确标注所有事件与等级均为测试数据。输出 `status=ok`、无恢复和警告。模型曾发起两次过宽的目录 glob,被技能访问隔离拒绝后自行改用指定技能目录完成,没有循环卡住。
同一最新技能的直接渲染为 624ms;8 个并发独立渲染进程全部成功,范围 650–949ms。围栏/截断/缺失字段/约 600KiB 场景输入的恢复、非空目录拒绝覆盖、脚本结束标签转义均完成专项检查。真实 Windows LocalSandbox 虚拟路径经 PowerShell 调用 Node 成功(含冷 shell 7.85s)。CSS、JS、菲律宾底图和旗帜资源均返回 200;2.93MB 底图一次请求耗时 208ms。8 个渲染进程不等于 8 个同时生成长内容的模型用户。
**最新 HTML 的视觉验收未通过:** 测试账户能在会话中看到生成文件卡片,文件接口返回 200,源码模式能显示 HTML、正确资源地址和页面结构,但 Codex 内嵌浏览器的预览面板空白;DOM 中 iframe 的 `src` 为 `blob:http://127.0.0.1:5174/...`,可访问树仍为 `about:blank`。普通地图组件的可见性不能替代这个 HTML 的验收,渲染器返回成功也不能证明最终视觉正确。已保留空白预览截图,未将此项计为通过。
进一步在内存中执行真实 `prepareGeneratedHtml`:11,269 字节输入变为 14,597 字节,原有 3 个脚本逐字保留、DOM id 与外链 URL 均不变,新增兼容脚本语法有效。暂未发现生成器或预处理破坏 HTML 的证据;故障停在预览加载/Blob 导航阶段,具体原因未确认,不将其归为已知浏览器限制,也不宣称已修复。
界面还复现了 TDesign 新手引导闲置时的 `reading 'element'` 异常:`current=-1` 时其 resize 监听仍读取不存在的步骤。简洁模式与智能体两处包装组件已改为只有有效步骤才挂载 Guide。浏览器复测了文件面板开关、侧栏切换、手动启动、下一步、跳过,未再出现新的同类异常;完成/跳过的持久化语义保持不变。
## 合并后回归
- 完整 harness:274/274 通过,53.07s;包含前述技能及 Todo 子集,不重复累加。
- 模型并发配置专项:10 项通过;种子技能资源:4 项通过。
- 前端流式恢复专项:9 组共 44 项通过;待办可见性及相关消息分组:12 项通过。
- 全量前端构建和类型检查最初并行运行时,系统空闲内存降到约 17MB,因此主动停止该轮类型检查,改为串行执行;该轮中断不作为代码失败。
- `pnpm build` 通过(10m25s,包含前期资源争用,有大 chunk 警告)。其后新增的两个 Guide 挂载条件已通过单独语法检查和真实浏览器回归;另补齐问题导航抽取后的类型导入,不改变生成代码行为。
- 最终 `pnpm typecheck` 仍未通过,共 73 项原有诊断;与测试前本地 `hang-typecheck.log` 的 75 项逐条比较,减少了消息分组测试的 2 项,没有新增诊断。本轮发现的 `QuestionItem` 类型导入遗漏已经修正,复跑后消失。剩余问题包括 `nextra` 依赖、旧 JSX 类型和既有组件/测试类型,未扩大范围处理,不能标记全量类型检查通过。
## 本地证据和复测入口
详细证据仅保留在 `.runtime/test-results/20260928-local-qa/`,不提交模型凭证、问答日志、测试数据库、构建 ZIP 或技能临时大目录:
- `qa-concurrency-results.json`:逐轮时间、结束事件、答案检查和清理结果。
- `qa-concurrency-20-results.json`、`harness-full.xml`:20 个独立用户问答和完整 harness 回归。
- `knowledge-stable-recheck.json`、`knowledge-library-report.md`:知识库与资料库稳定复测及限制。
- `skills-benchmark-paired.json`、`skills-findings.md`:同机旧/新刷新配对;“旧逻辑”在隔离进程禁用新合并函数回放,不改服务源码。
- `philippines-html.json`、`large-skill.json`:旧页面技能能力缺口与公文技能真实调用。
- `philippines-map-ui.png`:普通地图真实浏览器渲染。
- `cognitive-philippines.json`、`cognitive-skill-probe.json`:最新技能真实完整问答及渲染器专项。
- `cognitive-preview-ui.png`:最新 HTML 在本轮内嵌浏览器出现空白的实际截图。
- `frontend-build.log`、`frontend-typecheck-final.log`:全量构建与最终类型检查。
提交的回归入口包括 `packages/harness/tests/test_skill_view_refresh_coalescing.py`、`test_skill_view_waits.py`、`test_sandbox_command_liveness.py`、`test_run_completion_timeout.py`、`test_todo_reminder_visibility.py`,以及前端 `control-timeout`、`stream-terminal-recovery` 和 `todo-reminder-visibility` 测试。
@@ -1,6 +1,6 @@
# 技能并发与流式修复:配置、前端包与验收说明
日期:2026-09-27。
日期:2026-09-27(2026-09-28 补充高并发运行配置)。
本说明对应本次提交的修改,包含模型流式兼容与恢复、React #185 错误处理和已完成的最小技能示例,
以及可选的技能视图复用、版本缓存、模型计时日志队列和 Gateway 线程池扩容。
@@ -10,7 +10,7 @@
- **后台代码也要更新**:四个新开关需要新版后台读取。只换前端 dist 或只把字段写进旧版 config.yaml 不会实现后台优化。
- **前端**:使用本说明末尾的 ZIP,里面是完整 `dist/`,不是源码或 node_modules。
- **默认不启用优化**:仓库两份 config.yaml 的四个新开关均为 `false`。只有主动改为 `true` 才启用对应项。
- **仓库运行配置已开启优化**:两份 config.yaml 的四个开关均已显式设为 `true`。但已有服务器的持久化/外挂配置不会被仓库自动覆盖,仍需手工同步。
- 开启线程池开关后可将上限调整为 100,但不提高模型服务商配额、不启用多 worker。
## 2. .env 需要修改哪些项
@@ -54,8 +54,9 @@ DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE=100
## 3. config.yaml 新增配置
在实际加载的 `config.yaml` **顶层**新增一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
也不要重复定义两个 `runtime_performance`:
在实际加载的 `config.yaml` **顶层**配置这一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
也不要重复定义两个 `runtime_performance`。下面是程序在字段缺失时的兼容性默认值;
当前仓库的两份可运行配置已使用后文的高并发开启值:
```yaml
runtime_performance:
@@ -92,7 +93,7 @@ runtime_performance:
- 已设置 `DEER_FLOW_CONFIG_PATH` 时,以它指向的文件为准;Docker 内该值必须是容器内可访问路径。
- 本仓库包含 `offline-backend-20260512/config.yaml` 和 `offline-backend-20260512/backend/config.yaml`;
两份都已补齐默认关闭的示例,但服务器要修改的是启动时实际加载/挂载的那一份。
两份已同步为高并发开启值,但服务器要修改的仍是启动时实际加载/挂载的那一份。
- 启动日志 `Loaded AppConfig from ...` 可确认路径,`RUNTIME_PERFORMANCE` 可确认实际开关值。
## 4. 开启后做了什么,没做什么
@@ -112,6 +113,9 @@ runtime_performance:
没有采用会延迟下线的 TTL 缓存;下一次工具调用能发现下线或无效配置并拒绝访问。
已经开始执行的工具不因此被强行中断。
缓存仅用于标准 `LocalSkillStorage`,自定义存储实现/子类保守回到原刷新路径。
同一 Gateway 进程内,仅在多个热命中调用真正重叠时合并为一次元数据校验;
该批调用结束后结果立即丢弃,新调用仍重新校验。启停文件哈希不同的调用不会加入旧校验,
校验异常会对该批调用统一 fail-closed,且不会阻止后续调用重试。
热路径仍有 O(文件数) 元数据校验,慢磁盘/网络挂载仍可能耗时;这不是“所有文件操作零开销”,
也没有把受锁保护的重建变成无锁写入。
@@ -0,0 +1,73 @@
# 问答停在执行命令或读取技能:排查与修复
日期:2026-09-28。
本次检查针对普通问答和智能体问答:页面停在工具步骤,上游模型没有收到下一轮请求。
以下是代码审查和受控回归确认的触发路径;尚未收到具体生产对话 ID 和现场日志,不能据此认定某次现场故障只有一个根因。
## 已修复的等待路径
| 位置 | 原来的触发条件 | 当前行为 |
| --- | --- | --- |
| LocalSandbox 命令执行 | `subprocess.run` 超时仅终止外层 shell,后代仍持有输出管道;Windows 超时收尾继续无期限 `communicate` | 独立进程组、关闭交互输入、临时文件采集输出;超时清理进程树并限制回收等待 |
| 技能视图刷新 | 进程锁、跨进程文件锁和等待并发校验结果均没有统一等待上限 | 等待最多 30 秒后抛出明确工具错误;不删除正在执行的刷新,不使用未经校验的旧视图 |
| AIO 沙箱 | 上次命令未返回时,后续命令或文件操作无限等待同一把锁 | 锁等待 30 秒后失败;不抢占旧请求的锁,不自动重复执行命令 |
| 文件分段读取 | 超长单行先读完整行才裁剪;FIFO/设备文件可能一直等数据 | 达到当前行输出预算即返回;本地读取用非阻塞打开并检查普通文件,远端分段读取同步限制 |
| 前端结束恢复 | 后端已 success/error,但旧 SSE 仍开着,SDK `isLoading` 一直为真;整段 SSE 期间的 `sendInFlight` 又把同一轮结束快照丢掉 | 核对轮次及运行 ID 后清理重连标记、关闭本地 reader、应用结束快照;不因此取消后端或覆盖新一轮 |
| 前端恢复查询 | `/runs`、检查点或历史请求悬挂,占着 single-flight 恢复槽 | 普通 SDK 请求设置 30 秒超时;恢复失败仍保留后续检查机会,SSE 不受此总时长限制 |
| 后端结束收尾 | 图已返回,但检查点检查、状态写入、日志刷新或标题同步永远不返回,导致 `end` 发不出去 | 终态检查、终态状态写入分别有 10 秒预算;后续元数据收尾共享 10 秒预算,独立 finally 发出 `end` |
技能锁超时走原有工具错误处理链,模型可收到失败结果并决定后续操作;不会把“超时”伪装成成功。
元数据收尾超时只发生在图已经结束后,不给整个研究/子智能体执行增加统一短时限。
终态检查点超时会报告运行失败,不能直接当成模型成功。
## 配置和上线
本次需同时更新后端代码和前端构建产物,重启后端后生效。只刷新页面或调线程数不能替代修复。
本次未操作生产服务、未发布前端包;正式离线发版仍使用独立的 `deerflow-offline-deployment` 仓库。
本地命令保留原来的 600 秒默认执行时限,新版允许在实际加载的配置文件中调整:
```yaml
sandbox:
# 合并到已有 sandbox 段中,保留 use、mounts 等既有字段。
command_timeout_seconds: 600
```
该字段只控制 LocalSandbox 命令执行,必须为有限正数。未配置仍为 600 秒;不要把它误认为模型请求超时。
命令输出每个流最多读入 1 MiB 头尾内容,再经过既有工具输出裁剪。
运行中的命令约每 100ms 检查累计捕获输出,超过 16 MiB 请求终止;这是采样护栏,不是磁盘硬配额。
正常退出后的显式后台进程需要自行重定向输出和管理生命周期。
前端已有的无业务事件检查窗口仍为 90 秒;这是启动后台状态核对的时机,不是宣判任务失败的时限。
30 秒控制请求超时不影响合法的长时间 SSE 和工具执行。
## 仍需现场证据区分的情况
- 磁盘/NFS 本身阻塞的刷新执行者仍可能卡在操作系统 I/O;本次限制的是其他调用的等待,不宣称能终止任意内核 I/O。
- 自定义技能脚本自身请求外部服务不返回,仍会等到本地命令时限;建议技能内各 HTTP 请求也设置连接/读取超时。
- 整个图中的第三方异步工具、模型前中间件和检查点操作没有统一的“无事件即取消”规则,避免误杀合法研究任务。
- 检查点回滚是实际数据修改,保持原有完成/取消语义,不把仍在执行的回滚丢到后台后开放下一轮。
- 已提交给数据库的单条写入未必能被驱动撤销;取消和新轮次检查可阻止旧清理继续发起后续写入,不能保证撤回已经执行的 SQL。
再次出现时,记录对话 ID、运行 ID、最后一步和时间,并对照上游请求日志、运行状态及
`RUN_COMPLETION_TIMEOUT run_id=... phase=terminal_checkpoint|terminal_status|metadata_cleanup`。
技能锁超时会包含 `Timed out waiting for skill view ...`;沙箱命令超时会包含 `Command timed out`。
不要仅凭页面最后显示“读取技能”就断定仍在读文件,它也可能是结束消息没有传到浏览器。
## 回归入口
- 后端 `packages/harness/tests/test_skill_view_waits.py`:锁竞争和并发校验超时、失败后恢复。
- 后端 `packages/harness/tests/test_skill_view_cache.py`、`test_skill_view_tool_reuse.py`:既有启停与缓存安全性。
- 后端 `packages/harness/tests/test_sandbox_command_liveness.py`:真实父子进程、输出、非交互输入及特殊文件/长行。
- 后端 `packages/harness/tests/test_run_completion_timeout.py`:挂起的结束收尾、取消、新一轮隔离及正常长图执行。
- 前端 `src/core/api/control-timeout.test.ts`、`src/core/threads/stream-terminal-recovery.test.ts`:控制请求超时及真实 SDK reader 恢复。
- 前端既有 `stream-recovery`、`stream-display`、`stream-reasoning-replay`、`stream-cursor` 测试继续验证断流重连和消息显示。
## 本机验证结果
- 后端 `python -m pytest packages/harness/tests -q`:**263 passed**。旧同步日志断言补了显式测试配置,避免当前启用异步日志时与后台队列竞速;异步日志仍由独立测试覆盖,未改实际运行配置。
- 前端八个相关测试文件打包后用 Node test runner 执行:**31 passed**;新增测试类型修正后其中三个新增用例再次通过。
- 修改的后端代码和测试通过 Ruff;`git diff --check` 通过。
- 完整前端类型检查未通过:**75 个 TypeScript 错误**,均在本次未修改的文件,涉及 `nextra` 缺失、旧 JSX 类型、既有测试类型等。本次修改文件及新增测试无诊断。详细输出保留在 `frontend-web/.runtime/hang-typecheck.log`(本机文件,不作为发版资产)。
- 命令进程树测试在 Windows 使用真实子进程;Linux 进程组分支已审查,但本机未做 Linux 实跑,AIO 未连接实际容器。未用生产模型调用或真实业务技能执行验证。
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN" suppressHydrationWarning data-browser-compat="firefox52">
<head>
<meta charset="UTF-8" />
<script>
(function () {
try {
var root = document.documentElement;
root.setAttribute("data-browser-compat", "firefox52");
var theme = localStorage.getItem("strategy-theme");
root.classList.remove("dark", "light");
if (theme !== "light") root.classList.add("dark");
} catch (error) {}
})();
(function () {
try {
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
return;
}
AbortSignal.timeout = function (milliseconds) {
var controller = new AbortController();
var timer = setTimeout(function () {
var reason;
try {
reason = new DOMException("signal timed out", "TimeoutError");
} catch (error) {
reason = new Error("signal timed out");
reason.name = "TimeoutError";
}
try {
controller.abort(reason);
} catch (error) {
controller.abort();
}
}, milliseconds);
controller.signal.addEventListener("abort", function () {
clearTimeout(timer);
});
return controller.signal;
};
} catch (error) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
<script src="/runtime-config.js" data-runtime-config></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,72 @@
"use strict";
const { transformSync } = require("@babel/core");
const presetEnv = require("@babel/preset-env");
// Firefox 52 implements async functions, but async iteration arrived later.
// Babel must therefore lower both async-generator declarations and `for await`
// consumers. Leaving the latter to SWC turns them into a synchronous
// `value[Symbol.iterator]()` loop and breaks LangGraph streams at runtime.
const ASYNC_ITERATION_RE =
/async\s+(?:function\s*)?\*|\bfor\s+await\s*\(/;
module.exports = function firefox52LegacyGeneratorLoader(source, inputMap) {
if (!ASYNC_ITERATION_RE.test(source)) {
this.callback(null, source, inputMap);
return;
}
this.cacheable?.(true);
const common = {
filename: this.resourcePath,
babelrc: false,
configFile: false,
sourceType: "unambiguous",
sourceMaps: false,
compact: false,
};
// Babel's regenerator transform cannot consume several modern expressions
// inside async generators in one pass. Lower those expressions first while
// deliberately preserving async/generator syntax, then run the Firefox 52
// pass. Only 24 LangChain/LangGraph modules currently enter this slow path.
const normalized = transformSync(source, {
...common,
presets: [
[
presetEnv,
{
targets: { ie: "11" },
bugfixes: true,
modules: false,
useBuiltIns: false,
exclude: [
"@babel/plugin-transform-async-generator-functions",
"@babel/plugin-transform-async-to-generator",
"@babel/plugin-transform-regenerator",
// Preserve `for await` for the second pass. Transforming it as a
// plain `for of` here loses async-iterator semantics permanently.
"@babel/plugin-transform-for-of",
],
},
],
],
});
const result = transformSync(normalized?.code ?? source, {
...common,
presets: [
[
presetEnv,
{
targets: { firefox: "52" },
bugfixes: true,
modules: false,
useBuiltIns: false,
},
],
],
});
this.callback(null, result?.code ?? normalized?.code ?? source, inputMap);
};
@@ -0,0 +1,44 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const loader = require("./legacy-generator-loader.cjs");
function compile(source) {
let output;
let thrown;
loader.call(
{
resourcePath: "langgraph-sdk/dist/ui/manager.js",
cacheable() {},
callback(error, code) {
thrown = error;
output = code;
},
},
source,
undefined,
);
if (thrown) throw thrown;
return output;
}
test("keeps for-await asynchronous while lowering it for Firefox 52", () => {
const output = compile(`
export async function consume(run) {
for await (const value of run) {
consumeValue(value);
}
}
`);
assert.match(output, /_asyncIterator\(run\)/);
assert.doesNotMatch(output, /run\[Symbol\.iterator\]/);
assert.doesNotMatch(output, /for\s+await/);
});
test("passes ordinary modules through without invoking Babel", () => {
const source = "export const value = 42;";
assert.equal(compile(source), source);
});
@@ -0,0 +1,7 @@
import autoprefixer from "autoprefixer";
import firefox52RgbVars from "../../scripts/postcss-firefox52-rgb-vars.mjs";
import firefox52Where from "../../scripts/postcss-firefox52-where.mjs";
export default {
plugins: [firefox52RgbVars, firefox52Where, autoprefixer],
};
@@ -0,0 +1,15 @@
import tailwindcss from "tailwindcss";
import darkDescendant from "../../scripts/postcss-dark-descendant.mjs";
import firefox52RgbVars from "../../scripts/postcss-firefox52-rgb-vars.mjs";
import firefox52Where from "../../scripts/postcss-firefox52-where.mjs";
import autoprefixer from "autoprefixer";
export default {
plugins: [
tailwindcss,
darkDescendant,
firefox52RgbVars,
firefox52Where,
autoprefixer,
],
};
@@ -0,0 +1,229 @@
"use strict";
function replaceRequired(source, search, replacement, label) {
const result = source.split(search).join(replacement);
if (result === source) {
throw new Error(`Firefox 52 compatibility patch no longer matches ${label}`);
}
return result;
}
module.exports = function firefox52SourceCompatLoader(source) {
this.cacheable?.(true);
const id = this.resourcePath.replaceAll("\\", "/");
if (/\/p-queue\/dist\/index\.js$/.test(id) && source.includes("#idAssigner = 1n;")) {
return replaceRequired(source, "#idAssigner = 1n;", "#idAssigner = 1;", "p-queue");
}
if (/\/mdast-util-gfm-autolink-literal\/lib\/index\.js$/.test(id)) {
return replaceRequired(
source,
"(?<=^|\\s|\\p{P}|\\p{S})",
"",
"mdast-util-gfm-autolink-literal",
);
}
if (id.includes("/oniguruma-parser/")) {
return source
.split("\\p{Alpha}\\p{Pc}").join("A-Za-z_")
.split("\\p{AHex}").join("[A-Fa-f0-9]")
.split("\\p{Alpha}").join("[A-Za-z]")
.split('"gsu"').join('"gu"');
}
if (/\/node_modules\/mermaid\/dist\/chunks\/.*\.mjs$/.test(id)) {
// Mermaid's generated Jison lexers put `break` immediately after `return`.
// Firefox 52 reports every one as an unreachable-code warning. Removing
// those dead statements is semantics-preserving and keeps DevTools usable.
return source.replace(
/(\breturn(?:\s+[^;\r\n]+)?;)(\r?\n[ \t]*)break;/g,
"$1$2",
);
}
if (/\/web-streams-polyfill\/dist\/ponyfill\.es5\.mjs$/.test(id)) {
// The distributed ES5 ponyfill declares a local variable after an
// unconditional return. Move the declaration before the return so Firefox
// does not report it as unreachable while preserving its function scope.
let result = replaceRequired(
source,
"function xt(e){return",
"function xt(e){var o;return",
"web-streams ponyfill local declaration",
);
return replaceRequired(
result,
";var o}function Yt",
";}function Yt",
"web-streams ponyfill unreachable declaration",
);
}
if (/\/node_modules\/@xyflow\/react\/dist\/esm\/index\.(?:js|mjs)$/.test(id)) {
// React Flow 12.12 uses BigInt only as a monotonically increasing render
// serial. Firefox 52 has no BigInt; Number preserves the queue semantics
// because this counter cannot approach MAX_SAFE_INTEGER in one session.
let result = replaceRequired(
source,
"useState(BigInt(0))",
"useState(0)",
"@xyflow/react queue serial initializer",
);
return replaceRequired(
result,
"setSerial(n => n + BigInt(1))",
"setSerial(n => n + 1)",
"@xyflow/react queue serial increment",
);
}
if (id.includes("/node_modules/react-router/")) {
let result = source;
if (result.includes("async function loadRouteModule(")) {
const start = result.indexOf("async function loadRouteModule(");
const endMarker = "\n\n// lib/dom/ssr/links.ts";
const end = result.indexOf(endMarker, start);
if (end < 0) {
throw new Error("Firefox 52 compatibility patch no longer matches React Router route modules");
}
const compatible = `async function loadRouteModule(route, routeModulesCache) {
if (route.id in routeModulesCache) {
return routeModulesCache[route.id];
}
throw new Error("External React Router route modules require native dynamic import and are unavailable in Firefox 52");
}`;
result = result.slice(0, start) + compatible + result.slice(end);
}
if (result.includes("function prefetchModule(modulePath)")) {
result = replaceRequired(
result,
`function prefetchModule(modulePath) {
import(
/* @vite-ignore */
/* webpackIgnore: true */
modulePath
);
}`,
`function prefetchModule(modulePath) {
return Promise.resolve(modulePath);
}`,
"React Router module prefetch",
);
}
const dynamicImportPattern = /import\(\s*\/\* @vite-ignore \*\/\s*\/\* webpackIgnore: true \*\/\s*/g;
const withoutIgnoredImports = result.replace(
dynamicImportPattern,
"firefox52UnsupportedDynamicImport(",
);
if (withoutIgnoredImports !== result) {
result = `${withoutIgnoredImports}
function firefox52UnsupportedDynamicImport(modulePath) {
return Promise.reject(new Error(
"External React Router module " + modulePath + " requires native dynamic import",
));
}`;
}
return result;
}
if (/\/node_modules\/zod\/v4\/core\/util\.js$/.test(id)) {
return source
.split('int64: [/* @__PURE__*/ BigInt("-9223372036854775808"), /* @__PURE__*/ BigInt("9223372036854775807")],')
.join("int64: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],")
.split('uint64: [/* @__PURE__*/ BigInt(0), /* @__PURE__*/ BigInt("18446744073709551615")],')
.join("uint64: [0, Number.MAX_SAFE_INTEGER],");
}
if (/\/langsmith\/dist\/_openapi_client\/internal\/utils\/path\.(?:js|cjs)$/.test(id)) {
const original = `const invalidSegmentPattern = /(?<=^|\\/)(?:\\.|%2e){1,2}(?=\\/|$)/gi;
let match;
// Find all invalid segments
while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) {
invalidSegments.push({
start: match.index,
length: match[0].length,
error: \`Value "\${match[0]}" can\\'t be safely passed as a path parameter\`,
});
}`;
const compatible = `const invalidSegmentPattern = /(?:^|\\/)((?:\\.|%2e){1,2})(?=\\/|$)/gi;
let match;
// Firefox 52 has no RegExp lookbehind. Keep the separator in the full
// match and report only the captured path segment.
while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) {
const segment = match[1];
invalidSegments.push({
start: match.index + match[0].length - segment.length,
length: segment.length,
error: \`Value "\${segment}" can\\'t be safely passed as a path parameter\`,
});
}`;
return replaceRequired(source, original, compatible, "langsmith path validation");
}
if (/\/langsmith\/dist\/utils\/sampling\.js$/.test(id)) {
let result = replaceRequired(
source,
"const SAMPLING_HASH_MODULUS = 1000000n;",
'const SAMPLING_HASH_MODULUS = typeof BigInt === "function" ? BigInt(1000000) : 1000000;',
"langsmith sampling modulus",
);
return replaceRequired(
result,
"const bucket = XXH3_128(encoder.encode(identifier.toLowerCase())) % SAMPLING_HASH_MODULUS;\n return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate;",
`const hash = XXH3_128(encoder.encode(identifier.toLowerCase()));
const bucket = hash instanceof Uint8Array
? new DataView(hash.buffer, hash.byteOffset, hash.byteLength).getUint32(0, false) % 1000000
: hash % SAMPLING_HASH_MODULUS;
return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate;`,
"langsmith sampling fallback",
);
}
if (/\/langsmith\/dist\/utils\/xxhash\/xxhash\.js$/.test(id)) {
const fallback = `
function hash128WithoutBigInt(data) {
const states = [2166136261, 2246822507, 3266489909, 668265263];
const primes = [16777619, 3266489917, 668265263, 374761393];
for (let i = 0; i < data.length; i++) {
const value = data[i] + i;
for (let j = 0; j < states.length; j++) {
states[j] = Math.imul(states[j] ^ (value + j * 97), primes[j]) >>> 0;
states[j] = (states[j] ^ (states[j] >>> 13)) >>> 0;
}
}
const result = new Uint8Array(16);
const view = new DataView(result.buffer);
for (let i = 0; i < states.length; i++) {
let value = states[i] ^ data.length;
value = Math.imul(value ^ (value >>> 16), 2246822507) >>> 0;
value = Math.imul(value ^ (value >>> 13), 3266489909) >>> 0;
view.setUint32(i * 4, (value ^ (value >>> 16)) >>> 0, false);
}
return result;
}
`;
let result = replaceRequired(
source,
"const n = (n) => BigInt(n);",
'const n = (value) => typeof BigInt === "function" ? BigInt(value) : 0;',
"langsmith BigInt initializer",
);
result = replaceRequired(
result,
"export function XXH3_128(data, seed = n(0)) {",
`${fallback}\nexport function XXH3_128(data, seed = n(0)) {\n if (typeof BigInt !== "function" || typeof BigUint64Array !== "function" || typeof DataView.prototype.getBigUint64 !== "function") {\n return hash128WithoutBigInt(data);\n }`,
"langsmith XXH3 fallback",
);
return replaceRequired(
result,
"export function xxh128ToBytes(hash128) {",
"export function xxh128ToBytes(hash128) {\n if (hash128 instanceof Uint8Array) return hash128;",
"langsmith byte conversion",
);
}
return source;
};
@@ -0,0 +1,52 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const loader = require("./source-compat-loader.cjs");
function compile(source, resourcePath) {
return loader.call({ resourcePath, cacheable() {} }, source);
}
test("replaces only XYFlow's render serial BigInt operations", () => {
const output = compile(
"const [serial, setSerial] = useState(BigInt(0)); setSerial(n => n + BigInt(1));",
"C:/repo/node_modules/@xyflow/react/dist/esm/index.js",
);
assert.equal(
output,
"const [serial, setSerial] = useState(0); setSerial(n => n + 1);",
);
});
test("fails when the pinned XYFlow source no longer matches", () => {
assert.throws(
() => compile("const serial = 0;", "C:/repo/node_modules/@xyflow/react/dist/esm/index.js"),
/compatibility patch no longer matches @xyflow\/react queue serial initializer/,
);
});
test("removes Mermaid Jison breaks that Firefox reports as unreachable", () => {
const output = compile(
'switch (rule) {\n case 1:\n return "TEXT";\n break;\n case 2:\n return 2;\n break;\n}',
"C:/repo/node_modules/mermaid/dist/chunks/mermaid.core/flow.mjs",
);
assert.doesNotMatch(output, /break;/);
assert.match(output, /return "TEXT";/);
assert.match(output, /return 2;/);
});
test("moves the web streams ponyfill declaration before its return", () => {
const output = compile(
"function xt(e){return make(e);var o}function Yt(e){return e}",
"C:/repo/node_modules/web-streams-polyfill/dist/ponyfill.es5.mjs",
);
assert.equal(
output,
"function xt(e){var o;return make(e);}function Yt(e){return e}",
);
});
@@ -0,0 +1,55 @@
# Firefox 52 第三方依赖兼容清单
本文记录 `fix/firefox52-compat` 分支中会影响 Firefox 52 的依赖、处理方式和回归入口。版本以 2026-10-09 的 `pnpm-lock.yaml` 实际解析结果为准;升级表内依赖后必须重新执行文末检查。
## 分级
| 等级 | 含义 | 当前处理 |
|---|---|---|
| A | 原生可运行,或由项目统一 SWC 转换即可运行 | 使用 Rspack 的 Firefox 52 单目标转换 |
| B | 语法可转换,但缺少浏览器 API | 在 `src/core/compat/firefox52.ts` 做能力检测后安装 polyfill |
| C | 依赖源码含 async iterator 等需要额外 Babel 转换的语法 | 精确 allowlist 后交给专用 loader |
| D | 含 BigInt、危险正则或平台实现差异,普通转译不能解决 | 固定版本并做带断言的精确源码替换或替代实现 |
| E | Firefox 52 网络层不提供现代流式 fetch 等能力 | 提供保持业务协议的运行时回退 |
## 已验证依赖
| 依赖/能力 | 实际版本 | 等级 | 处理与验证 |
|---|---:|:---:|---|
| React / React DOM | 18.3.1 | A | 兼容分支从 React 19 降为 18.3;所有页面统一使用这一运行时。118 条路由矩阵用于发现 Provider/Hook 运行时问题。 |
| React Router DOM | 7.18.4 | D | `source-compat-loader.cjs` 处理动态路由模块加载差异;Hash 路由、重定向、刷新、公开页和 iframe 均进入完整路由冒烟。 |
| Tailwind CSS | 3.4.19 | A | 采用 Tailwind 3 生成静态 utility;PostCSS 展平 `@layer`,转换 `:where()`、暗色后代规则和 CSS 变量透明色。 |
| Rspack | 2.2.8 | A | `target: ["web", "es5"]`,SWC/Lightning CSS 的目标均为 Firefox 52;使用普通 script 与 Rspack chunk loader。 |
| `@langchain/core` | 1.2.17 | C | LangChain 包进入 async-iteration loader;真实问答验证 thread 创建、XHR 流、导航和最终回复。 |
| `@langchain/langgraph-sdk` | 1.12.3 | C | 对 async generator 和普通 async 方法中的 `for await` 做两阶段 Babel 转换;回归测试防止退化为同步 `Symbol.iterator`。 |
| LangSmith | 0.10.8 | D | 进入源码兼容 loader,替换 Firefox 52 不支持的 BigInt/哈希实现路径;产物静态扫描兜底。 |
| `p-queue` | 6.6.2 / 9.3.3 | D | 精确处理 BigInt 并发计数;loader 找不到预期源码时直接让构建失败,避免静默输出危险代码。 |
| React Flow (`@xyflow/react`) | 12.12.0 | D | 仅把内部渲染序号的 `BigInt(0)` 和递增改为 Number;流程图相关路由已在 Firefox 52 中加载。 |
| Shiki | 3.15.0 | D | 重型模块按需分块;其 Oniguruma JS 引擎进入下面的正则兼容处理。 |
| Oniguruma parser | 0.12.2 | D | 移除 Firefox 52 不支持且不改变当前匹配语义的正则 flag;代码高亮页面与产物语法检查共同验证。 |
| Mermaid | 11.17.2 | C | 由 Rspack/SWC 转换并按需加载;Markdown/研究/会商入口纳入路由矩阵。 |
| Zod | 3.24.x(直接依赖) | D | 依赖源码进入兼容 loader,处理 BigInt 上下界;扫描最终 chunks 是否残留危险语法。 |
| MapLibre GL | 4.7.1 | D | 固定在可转换版本;保持 Leaflet/静态信息回退,避免升级后引入 Firefox 52 无法执行的 Worker/语法。 |
| CodeMirror / Tiptap / KaTeX / Marked | 锁文件版本 | A/C | 由 SWC 统一转换并路由级加载;Markdown 编辑器、预览和 Word 导出均有真实浏览器冒烟。 |
| Radix UI / TDesign / cmdk | 锁文件版本 | A/B | Pointer Events、ResizeObserver、Portal 点击和滚动锁由兼容入口及分层 CSS 处理;菜单、选择器、弹窗、Tabs 有点击测试。 |
| `docx` / `file-saver` | 9.5.1 / 2.0.5 | B | 正式 Word 由后端生成;Firefox 52 冒烟验证非空 DOCX Blob、MIME 和 `.docx` 下载文件名。 |
| xlsx 解压 | fflate 0.8.3 | D | 浏览器端解压路径改为 fflate,避开原实现的现代能力依赖。 |
## 浏览器 API 与平台回退
`src/core/compat/firefox52.ts` 在应用入口最先加载,按能力补充 AbortController、AbortSignal、Observers、Web Streams、Pointer Events、`requestSubmit`、`scrollIntoView`、Clipboard、Blob/File、UUID 等能力。所有补丁先检查原生实现,现代浏览器不会被无条件覆盖。
Firefox 52 的 `fetch()` 没有可供业务消费的流式 `Response.body`。`src/core/compat/firefox52-fetch.ts` 用 `XMLHttpRequest.onprogress` 构造兼容的增量响应,保留请求头、鉴权、取消、错误状态和 SSE/NDJSON 分片语义。该路径属于 E 级平台回退,不能删除后只依赖 Web Streams polyfill。
## 升级规则
1. 修改表内版本后先运行 `pnpm test:firefox52-build`。精确源码片段不匹配会直接失败,此时需重新审计依赖实现,不能放宽成无条件替换。
2. 执行 `pnpm build:firefox52 && pnpm check:firefox52`,确认最终 HTML、CSS、JS 与所有动态 chunks 都能被 Firefox 52 解析。
3. 启动 Firefox 52 后执行 `pnpm smoke:firefox52:themes`、`pnpm smoke:firefox52:live` 和 `pnpm smoke:firefox52:full`。
4. LangChain、LangGraph、LangSmith、p-queue、React Router、XYFlow、Oniguruma、Shiki、Mermaid、MapLibre 任一升级,都必须重新跑真实流式问答和相关懒加载路由,不能只看构建成功。
## 当前限制
- WebGL 的最终可用性还受显卡、远程桌面和驱动影响;无 WebGL 环境应走现有的二维或文本信息回退。
- `1366×768`、`1920×1080`、`2560×1440` 的精确视觉截图需要对应大小的真实显示环境。当前自动化会读取真实 DOM 尺寸和样式,但不会伪造操作系统无法提供的物理视口。
- 创建、删除、发布、回滚等会修改业务数据的管理操作不在自动点击矩阵中;这些页面会验证加载、可见 Tabs 与未捕获异常,数据写入流程由专门测试数据验收。
@@ -0,0 +1,56 @@
# Firefox 52 路由与交互验收说明
## 自动化范围
`pnpm smoke:firefox52:full` 直接连接 Firefox 52 的远程调试端口,并以管理员会话逐一访问前端路由。矩阵当前包含 118 个入口,覆盖:
- 登录、落地页、公开页和独立会商页;
- 工作区、智能体、技能、AI 写作、深度研究、公文、企业研究和 Notebook;
- 多智能体会商、业务链、报告协作和知识空间;
- 定时任务、管理员页面、设置页面和模型相关页面;
- 任务管理、任务分析、行动分析、任务会商、资源管理和轻应用;
- canvas、notebook、strategy、iframe 与兼容重定向入口。
每条路由都会检查 URL/重定向、页面完整加载、根节点尺寸、可见内容、错误边界、未捕获 `PageError`、控制台错误和 chunk 加载错误。页面内所有可见且未禁用的 `[role="tab"]` 会依次点击,以触发非破坏性的懒加载面板。脚本不会点击保存、删除、发布、回滚等会修改业务数据的按钮。
默认使用应用正常的 SPA 导航。`FIREFOX_FULL_RELOAD=1` 可切换为逐路由整页刷新,建议配合 `FIREFOX_ROUTE_FILTER` 分批运行。Firefox 52 在远程调试器、开发 source map 和上百次连续整页刷新同时存在时会积累调试内存;默认模式可以覆盖全部入口,同时避免把调试器内存压力误判为某个页面故障。
## 核心交互与专项
`pnpm smoke:firefox52` 当前包含 20 项真实浏览器检查:
- 7 个核心页面加载;
- 左下角“设置和更多”、换肤色板;
- Agent、模型和常用提示词弹窗;
- 输入框 Enter 提交;
- 对话类型筛选、项目创建模式;
- AI 写作模型、会商业务链、定时任务新建弹窗;
- Markdown 编辑器重置;
- 调用正式后端接口生成非空 DOCX Blob,并校验 MIME 与 `.docx` 文件名。
`pnpm smoke:firefox52:live` 在上述基础上执行一轮真实问答。它验证表单提交、线程创建、路由跳转、XHR 增量流式读取和包含唯一校验标记的助手回复。`pnpm smoke:firefox52:themes` 验证六套主题乘以全量/简洁布局,共 12 种组合。
## 运行方式
```powershell
$env:FIREFOX_DEBUG_PORT = "6001"
pnpm smoke:firefox52
pnpm smoke:firefox52:live
pnpm smoke:firefox52:themes
pnpm smoke:firefox52:full
```
可用 `FIREFOX_ROUTE_FILTER` 只回归匹配的路由;冷编译较重的页面可通过 `FIREFOX_ROUTE_TIMEOUT_MS` 调整单页等待时间,默认 30 秒。测试账号默认 `admin`,可用 `FIREFOX_TEST_USERNAME` 和 `FIREFOX_TEST_PASSWORD` 覆盖。
## 2026-10-09 结果
| 项目 | 结果 |
|---|---|
| 核心页面与交互 | 20/20 通过 |
| 真实流式问答 | 1/1 通过 |
| 真实智能体流式问答 | 1/1 通过 |
| 主题 × 布局 | 12/12 通过 |
| 开发站点完整路由 | 118/118 通过,并点击 71 个可见非破坏性 Tabs |
| 生产预览完整路由 | 118/118 通过,并点击 74 个可见非破坏性 Tabs |
验收结论仅覆盖本次账号和现有测试数据。需要特定外部系统、任务深链参数、硬件 WebGL 或实际业务数据写入的流程,仍应在对应部署环境补充人工验收。
@@ -0,0 +1,579 @@
# Firefox 52 全功能兼容与 Rspack 高性能构建开发计划
## 1. 文档信息
| 项目 | 内容 |
|---|---|
| 目标分支 | `fix/firefox52-compat` |
| 独立工作区 | `F:\react01\firefox-web` |
| 前端目录 | `F:\react01\firefox-web\frontend-web` |
| 目标浏览器 | Firefox 52(Windows x64) |
| 技术方向 | React 18.3 + Tailwind CSS 3.4 + Rspack 2.x + 内置 SWC |
| 核心目标 | 尽可能保持现有全部功能,提高冷构建和增量构建速度,保证主要页面与主题样式 |
| 当前状态 | 兼容构建与自动回归已接通:核心交互 20/20、主题布局 12/12、完整路由 118/118,真实普通问答与智能体问答均通过 |
### 1.1 2026-10-09 实施进度
已完成:
- 建立 Firefox 52 专用 Rspack/SWC 单目标构建,开发服务器、快速构建、正式构建和预览命令均已接通;持久化缓存生效。当前机器实测冷生产构建 16.865 秒,首轮后的四次稳态构建中位数 1.584 秒、P95 1.641 秒。
- 运行时降级到 React 18.3;Tailwind CSS 采用 `feat/tailwind-v3-legacy-chrome` 已验证方案降级到 3.4,保留现有主题 token 和组件类名。
- 建立统一兼容入口,补齐 Pointer Events、`requestSubmit`、`scrollIntoView({block:"nearest"})`、Blob、UUID、Clipboard、ResizeObserver、Web Streams 等能力。
- 流式请求使用 XHR `onprogress` 适配 SSE/NDJSON/Range 等接口;单元测试 4/4 通过。
- 修复 Firefox 52 下的 SPA 路由白屏、`display: contents` 根布局失效、侧边栏分隔线颜色、首页宽度 token、欢迎标题透明、常用提示词换行、Radix 菜单无法点击、cmdk 下拉框崩溃和 Enter 无法发送。
- CSS 兼容样式拆到 `src/styles/firefox52/`;PostCSS 自动转换 `rgb(var(...) / alpha)`、`:where()` 和暗色后代选择器,并为 flex gap 提供回退。
- MapLibre 固定到 4.7.1,xlsx 解压改用 fflate;对 LangChain/LangGraph/LangSmith、Oniguruma、React Router、Zod 等依赖启用精确转换或源码兼容 loader。
- 修复 LangGraph 普通 async 方法中 `for await` 被错误降为同步迭代的问题,并增加 loader 回归测试;修复 XYFlow 12.12 的 BigInt 渲染序号导致流程页面白屏的问题。
- 增加 `check:firefox52-css`、`check:firefox52-js`、核心交互、真实流式、主题布局和 118 条完整路由脚本,用于阻止现代 CSS/JS 语法、路由和页面交互回归。
- 建立 `docs/firefox52-dependency-compatibility.md` 和 `docs/firefox52-route-compatibility.md`,记录版本、补丁原因、验证入口与平台限制。
本阶段验证结果:
| 验证项 | 结果 |
|---|---|
| Firefox 52 核心页面与交互冒烟 | 20/20 通过 |
| Firefox 52 真实普通问答 | 线程创建、路由跳转、XHR 增量流与最终回复均通过 |
| Firefox 52 真实智能体问答 | 自动选择可用智能体,线程创建、XHR 增量流与最终回复均通过 |
| 主题与布局 | 6 套主题 × 全量/简洁布局,12/12 通过 |
| Word 导出 | 后端实际生成非空 DOCX,MIME 与下载文件名通过 |
| 完整路由 | 开发与生产预览均为 118/118;分别点击 71/74 个可见非破坏性 Tabs;强制刷新模式支持按过滤器分批运行 |
| 关键交互 | 设置和更多、换肤、Agent/模型选择、提示词管理、Enter 提交、项目创建、业务链条、定时任务新建、Markdown 编辑器与 Word 下载 |
| 正式产物 CSS 检查 | 通过 |
| 正式产物 Acorn ES2017 解析 | 464/464,通过 |
| Firefox 52 浏览器自身 `new Function` 解析 | 464/464,0 错误 |
| 正式产物 JS 体积 | 25.2 MiB(464 个按需 chunk) |
| Rspack 生产构建 | 冷构建 16.865 秒;稳态中位数 1.584 秒、P95 1.641 秒 |
管理员、任务、iframe、公开页、canvas、会商和设置等静态入口已纳入完整路由矩阵。需要特定任务参数、外部系统、业务数据写入、硬件 WebGL 或精确物理分辨率的流程,仍需在对应部署环境补充人工验收,不能用静态入口通过替代。
## 2. 建设目标
本项目在独立兼容分支中建设 Firefox 52 专用前端构建链。现代浏览器版本继续在主开发目录使用现有 Vite 构建,兼容分支只生成 Firefox 52 单目标产物,不生成现代与旧版两套 bundle。
开发遵循以下目标:
1. **功能覆盖优先**:现有路由和主要业务流程都必须进入兼容验收矩阵,不能以“首页能够打开”作为完成标准。
2. **构建性能优先**:使用 Rspack、SWC、增量编译和持久化缓存,移除 legacy bundle 的二次 Babel/SystemJS 转换。
3. **样式一致性优先**:保留 Tailwind、TDesign、Radix 和自定义主题,系统处理 Firefox 52 不支持的 CSS 语法与布局能力。
4. **兼容逻辑可维护**:浏览器差异集中到构建配置、兼容入口、传输适配器和依赖补丁,不在业务组件中散落版本判断。
5. **持续跟进主分支**:兼容分支能够定期合并 `master-zg` 的新功能,并通过自动检查发现新增的不兼容依赖和语法。
## 3. 完成标准
### 3.1 功能标准
- Firefox 52 打开登录页、首页和工作区时没有白屏。
- 首屏与路由切换没有未捕获 JavaScript 异常、语法错误和 chunk 加载错误。
- 功能矩阵中的 P0、P1 场景全部通过;P2 场景逐项验证并记录结果。
- 无法由旧浏览器实现的原生能力必须提供等价回退,页面和主流程不能直接不可用。
- 文件上传、文件下载、流式问答、Markdown、公式、代码块、流程图和 Word 导出必须作为专项验收项。
- iframe、任务深链、公开分享页面和运行时配置必须保持现有 URL 与接口行为。
### 3.2 性能标准
先在阶段 0 获取当前 Vite legacy 的基线,后续使用相同机器、相同源码、相同依赖和相同环境重复测试。最终门槛如下:
| 指标 | 目标 |
|---|---|
| 开发服务器首次可访问时间 | 不高于基线的 50%,期望不超过 10 秒 |
| 修改单个 TSX 后的增量构建 | P95 不超过 1.5 秒 |
| 修改全局 CSS 后的增量构建 | P95 不超过 2 秒 |
| 无缓存生产构建 | 不高于基线的 50% |
| 有缓存生产构建 | 不高于基线的 30% |
| 连续构建稳定性 | 连续 10 次无随机失败、无失效 chunk、无内存溢出 |
| 产物浏览器检查 | 不含 Firefox 52 无法解析的模块入口和已知危险语法 |
绝对时间与相对提升发生冲突时,以“功能正确、样式正确、相对基线明显提升”为判断顺序。未达到性能门槛前不移除旧构建链。
### 3.3 样式标准
- 全量模式、简洁模式、嵌入模式均无整体布局塌陷。
- 浅色、深色、浅蓝、浅绿、冰蓝、浅黄等现有主题逐一验证。
- 重点分辨率至少覆盖 `1366×768`、`1920×1080`、`2560×1440`。
- 导航栏、侧边栏、表单、表格、标签页、弹窗、下拉框、Tooltip、抽屉和浮层没有明显错位或遮挡。
- 页面不得出现因 `@layer`、现代颜色语法或不支持的选择器导致的整片无样式。
- 主流程截图与现代浏览器参考图保持结构、间距、颜色和层级一致;字体抗锯齿造成的细微像素差异不作为失败。
## 4. 当前兼容基线
兼容分支已经存在以下措施,Rspack 迁移必须先等价承接,再进行优化:
1. `@vitejs/plugin-legacy` 生成 Firefox 52 的 SystemJS legacy bundle。
2. Mermaid legacy chunk 额外经过 Babel 转换。
3. 对 `p-queue`、GFM autolink、Oniguruma parser、LangSmith XXHash 进行构建期源码补丁。
4. 入口安装 AbortController、IntersectionObserver、ResizeObserver 和 Web Streams polyfill。
5. PostCSS 展平 Tailwind `@layer`。
6. 自定义颜色回退和 Lightning CSS 目标转换。
7. 全局样式中已经维护主题对应的 legacy 色值。
8. `index.html` 在应用入口前安装 `AbortSignal.timeout` 回退。
当前方案的主要问题:
- Vite 开发服务器依赖现代 ESM,Firefox 52 无法直接用于日常开发调试。
- legacy 构建对最终 chunks 再执行 Babel 和 SystemJS 转换,模块量越大耗时越明显。
- 依赖源码在构建过程中做字符串替换,缓存命中率、升级稳定性和错误定位都较差。
- Web Streams polyfill只能补充 JavaScript API,不能自动让 Firefox 52 的 `fetch` 响应具备网络流式读取能力。
- CSS 已有回退主要围绕旧版 Chrome 建设,还需要按 Firefox 52 实际渲染能力重新审计。
## 5. 总体技术方案
### 5.1 构建链
Firefox 52 专用构建使用以下组件:
- `@rspack/core`
- `@rspack/cli`
- `@rspack/dev-server`
- `builtin:swc-loader`
- Rspack SWC JavaScript minimizer
- Rspack Lightning CSS minimizer或现有 PostCSS + Lightning CSS 流程
- HTML 生成插件、资源复制插件和 React Refresh 集成(确认 Firefox 52 可运行后启用)
构建目标分成两层:
1. Rspack runtime 目标指定 Firefox 52,保证 chunk loader、bootstrap 和 HMR runtime 不输出危险语法。
2. SWC `env.targets` 指定 Firefox 52,负责 TS、TSX、JSX 和业务 JavaScript 的语法降级及 core-js 注入。
生产构建仅输出一套 Firefox 52 产物,使用普通 script/JSONP chunk loading,不保留 Vite legacy 的 SystemJS 双阶段处理。
### 5.2 配置与资源保持
迁移时保持以下外部行为:
- `runtime-config.js` 继续在应用 bundle 之前加载。
- 保持现有 `VITE_*` 配置键,不要求部署环境一次性改名。
- `@/env`、`@/*`、`@vite/*`、`next/navigation`、`next/link` 等 alias 保持一致。
- `base`、静态资源目录、favicon、字体和图片路径保持可配置。
- Hash 路由、BrowserRouter 深层路由、iframe 嵌入路由和公开页面刷新都能正确加载资源。
- 开发端口与启动脚本可以通过环境变量覆盖,默认不与主开发工作区冲突。
### 5.3 构建模式
计划提供以下命令:
| 命令 | 用途 |
|---|---|
| `npm run dev` / `pnpm dev` | 默认启动 Firefox 52 Rspack Dev Server,端口 5174 |
| `pnpm dev:firefox52` | Rspack Dev Server,供 Firefox 52 直接调试 |
| `npm run dev:vite` / `pnpm dev:vite` | 仅在现代浏览器调试时启动原 Vite Dev Server |
| `pnpm build:firefox52` | Firefox 52 正式单目标构建 |
| `pnpm build:firefox52:fast` | 不压缩、保留调试信息的快速验证构建 |
| `pnpm preview:firefox52` | 使用静态服务器验证正式产物 |
| `pnpm check:firefox52` | 扫描危险语法、资源路径和兼容清单 |
| `pnpm benchmark:firefox52` | 执行冷构建、热构建和增量构建基准 |
| `pnpm smoke:firefox52` | 运行 Firefox 52 核心页面与交互冒烟 |
| `pnpm smoke:firefox52:live` | 运行真实普通问答流式冒烟 |
| `pnpm smoke:firefox52:agent-live` | 选择当前可用智能体并运行真实流式冒烟 |
| `pnpm smoke:firefox52:themes` | 验证 6 套主题 × 2 种布局 |
| `pnpm smoke:firefox52:full` | 验证 118 个路由及可见非破坏性 Tabs |
类型检查继续单独运行 `pnpm typecheck`,不阻塞开发构建的模块转换。
## 6. JavaScript 与浏览器 API 兼容方案
### 6.1 兼容入口
建立唯一的 Firefox 52 兼容入口,并保证它早于 React、路由、请求客户端和业务模块执行。兼容入口统一处理:
- `Promise`、`Map`、`Set`、`Symbol`、迭代器及必要的 core-js 能力。
- AbortController、AbortSignal.timeout 和 abort reason 差异。
- ResizeObserver、IntersectionObserver、matchMedia 监听接口。
- ReadableStream、WritableStream、TransformStream。
- `queueMicrotask`、`globalThis`、`Object.fromEntries`、`Promise.finally` 等运行时能力。
- `crypto.randomUUID`、Clipboard、下载、全屏等能力的回退实现。
- `requestIdleCallback`、`structuredClone` 等组件可能间接使用的 API。
所有 polyfill 必须具备能力检测,只在缺失时安装,避免重复覆盖浏览器实现。
### 6.2 流式请求专项
流式问答是兼容工作的 P0 项。Firefox 52 的网络层不能依赖现代 `fetch().body.getReader()`,需建立统一传输适配器:
1. 现代浏览器继续使用现有 fetch/SSE/NDJSON 路径。
2. Firefox 52 使用 XHR `onprogress` 增量读取 `responseText`。
3. 适配器统一处理 SSE、NDJSON、取消、超时、断线、服务端错误和 UTF-8 分片。
4. LangGraph、普通问答、智能体问答、AI 写作、深度研究、会商、技能地址扫描等流式接口全部复用同一兼容层。
5. 增加分片边界测试,覆盖半个 JSON、半个 UTF-8 字符、连续空行、最后一行无换行和中途取消。
### 6.3 不可直接转换的语法
以下内容不能只依赖 SWC 或 core-js,必须通过升级、降级、替代或固定补丁解决:
- BigInt 字面量及 BigInt 专用算法。
- 正则向后断言、Unicode property escapes 和不支持的正则 flag。
- Module Worker 和依赖原生 ESM 的 Worker。
- 运行时拼接后才生成的现代语法。
- 强依赖新浏览器 Web API 的第三方组件。
处理顺序:优先选择兼容版本,其次维护专用 fork,再使用 `patch-package` 或安装后补丁。构建期间的逐文件字符串替换只作为临时迁移手段。
### 6.4 产物静态扫描
`check:firefox52` 至少扫描:
- 未被打包的 `import`、`export`、`import.meta`。
- BigInt 字面量和 `BigInt64Array`/`BigUint64Array` 直接依赖。
- 正则向后断言、Unicode property escapes、dotAll 等危险正则。
- 未处理的 async generator、module worker 和 module script。
- 绝对开发机路径、丢失的 chunk、错误 public path。
- HTML 中会被 Firefox 52 忽略且没有回退的入口脚本。
扫描结果必须支持白名单,白名单需要注明依赖、原因、验证方式和负责人,不能静默忽略。
## 7. 第三方依赖治理
### 7.1 依赖分级
建立 `docs/firefox52-dependency-compatibility.md`,按以下类别维护:
| 等级 | 含义 | 处理方式 |
|---|---|---|
| A | 原生兼容或经 SWC 即可兼容 | 正常打包 |
| B | 缺少 API polyfill | 加入统一兼容入口 |
| C | 包含可转换的新语法 | 加入 SWC 依赖 allowlist |
| D | 包含不可转换语法或新 API | 固定版本、fork、patch 或替换 |
| E | Firefox 52 平台能力无法完整支持 | 提供业务等价回退并记录差异 |
### 7.2 首批专项依赖
优先审计以下依赖:
- React 19、React DOM、React Router。
- LangGraph SDK、LangChain、AI SDK、LangSmith。
- Mermaid、Shiki、react-syntax-highlighter、Oniguruma。
- CodeMirror、Tiptap/ProseMirror、KaTeX、Marked、react-markdown。
- MapLibre GL、Leaflet、XYFlow、OGL、GSAP、Motion。
- Radix UI、TDesign React、cmdk、Embla。
- TanStack Query、Axios、Zod、date-fns、uuid。
- docx、file-saver 和文件预览相关模块。
重型模块保持路由级或组件级动态加载。Rspack 必须把动态 import 转为 Firefox 52 可用的普通 chunk loader。
## 8. CSS 与视觉一致性方案
### 8.1 CSS 构建顺序
CSS 建议按以下顺序处理:
1. Tailwind 4 生成样式。
2. 展平 `@layer`,防止 Firefox 52 忽略整组规则。
3. 生成颜色和透明度回退。
4. 处理嵌套、逻辑属性、选择器和可安全转换的现代语法。
5. Lightning CSS 按 Firefox 52 目标转换。
6. 生产模式压缩,开发模式保持未压缩和快速 source map。
需重点验证当前 `rgb(var(--legacy-*-rgb) / alpha)` 在 Firefox 52 中的实际表现;若浏览器不接受空格分隔和斜杠透明度语法,应在构建期生成逗号分隔的 `rgba()` 或直接颜色值。
### 8.2 手写兼容 CSS 与文件拆分
构建工具无法可靠转换,或者转换后仍存在布局和交互差异的样式,直接编写 Firefox 52 专用 CSS。兼容样式不得继续集中追加到 `globals.css`,统一放在独立目录中按职责拆分:
```text
src/styles/firefox52/
├── index.css # 只负责按固定顺序汇总核心兼容样式
├── tokens.css # 颜色、透明度、阴影和主题变量回退
├── base.css # 浏览器基础差异和通用元素修正
├── layout.css # 页面框架、侧边栏、滚动区和响应式布局
├── overlays.css # Dialog、Popover、Select、Tooltip、Guide 等浮层
├── markdown.css # Markdown、代码块、公式、表格和引用
├── editors.css # CodeMirror、Tiptap、ProseMirror 等编辑器
├── visualizations.css # Mermaid、XYFlow、图表、地图和 Canvas 容器
├── chat.css # 普通问答、智能体问答和消息时间线
├── writing-research.css # AI 写作、深度研究和公文工作台
├── roundtable.css # 会商、流程图和报告协作
├── task-workspaces.css # 任务深链、目的树和快捷跳转栏
└── admin.css # 管理端表格、表单和配置页
```
拆分规则:
1. 全局首屏必需的 `tokens/base/layout/overlays` 由 `index.css` 统一引入。
2. Markdown、编辑器、可视化、会商、任务工作区和管理端样式随对应路由或组件导入,让 Rspack 生成独立 CSS chunk,避免所有用户加载完整兼容样式。
3. 单个兼容 CSS 文件建议控制在 500 行或未压缩 30 KB 以内;超过任一门槛时继续按组件或业务域拆分。
4. HTML 根节点由兼容入口设置稳定标记,例如 `data-browser-compat="firefox52"`。兼容规则应挂在该标记下,防止影响现代浏览器和公共组件的默认样式。
5. 优先复用语义 token 和现有组件 class,不复制整份第三方组件样式。只覆盖 Firefox 52 实际不兼容的声明。
6. `!important` 只用于第三方组件无法通过正常层叠覆盖的场景,并在规则旁注明原样式来源和原因。
7. 文件加载顺序必须固定:主题 token → 基础样式 → 布局 → 通用浮层 → 业务页面。禁止依赖偶然的 chunk 到达顺序解决优先级。
8. 每个文件明确对应验收页面,删除页面或修复上游兼容性时同步清理失效规则。
如果某个功能仅需要少量兼容规则,样式文件放在功能模块旁并使用 `.firefox52.css` 后缀;跨页面共用的兼容规则才放入 `src/styles/firefox52/`。构建检查需要统计各文件行数、体积、重复选择器和 `!important` 数量,超过门槛时提示拆分。
### 8.3 CSS 风险清单
建立自动扫描和人工验证清单,覆盖:
- `color-mix()`、OKLCH、现代 `rgb()`/`hsl()`。
- `@layer`、CSS nesting、container query。
- `:has()`、`:focus-visible`、复杂 `:is()`/`:where()`。
- Flexbox `gap`、Grid 差异和 `subgrid`。
- `aspect-ratio`、`inset`、`min()`、`max()`、`clamp()`。
- `dvh`/`svh`/`lvh`、逻辑方向属性。
- `backdrop-filter`、mask、混合模式和复杂滤镜。
- sticky、滚动容器、overscroll 和自定义滚动条。
- Portal 浮层、z-index、transform containing block 和 `pointer-events`。
无法可靠转换的布局功能通过兼容 utility 或组件级基础样式回退解决,不能只隐藏出错区域。
### 8.4 视觉回归范围
每种主题至少截取以下代表页面:
1. 登录页和新对话落地页。
2. 普通问答流式消息页。
3. 智能体列表、创建页和智能体聊天页。
4. AI 写作工作台。
5. 多智能体会商三步工作台和流程图。
6. 知识库列表、编辑器和知识图谱。
7. 任务管理、目的树弹窗和快捷跳转栏。
8. 管理端表格、Tabs、表单和确认弹窗。
9. iframe 嵌入页和公开分享页。
10. 站点预览、地图、图表和全屏场景。
视觉验收重点比较布局边界、间距、颜色、滚动区域、遮罩和弹层位置。截图自动化优先使用与 Firefox 52 匹配的 Selenium/geckodriver;无法稳定自动化的页面纳入人工截图清单。
## 9. 全功能验收矩阵
### 9.1 P0:必须首先打通
| 功能域 | 核心场景 |
|---|---|
| 登录与鉴权 | 密码登录、URL 参数登录、退出、刷新恢复、鉴权失效 |
| 页面框架 | 全量/简洁模式、侧边栏、菜单、主题、路由刷新 |
| 普通问答 | 新建会话、历史会话、流式回答、停止、重试、错误显示 |
| 消息展示 | Markdown、GFM、代码高亮、公式、链接、引用、流程图 |
| 文件 | 上传、附件卡片、产物预览、下载、Word 导出 |
| 智能体问答 | 智能体列表、进入聊天、新建和历史会话 |
| iframe | `embed=1`、`hideJump=1`、主题、独立鉴权、postMessage |
| 错误恢复 | 断网、接口 401/403/500、chunk 加载失败、刷新恢复 |
### 9.2 P1:主要业务能力
| 功能域 | 核心场景 |
|---|---|
| 智能体管理 | 创建、编辑、模型、知识空间、SOUL、技能、保存、引导 |
| 舆情分析智能体 | 外部流式接口、会话、错误信息 |
| AI 写作 | 经典版、对话版、阶段推进、干预、资料、Markdown 与 Word 下载 |
| 深度研究/公文 | 发起任务、过程状态、恢复、结果展示和下载 |
| 多智能体会商 | 普通、规划、8BF、岗位会商、链条配置、后台续跑 |
| 报告协作 | 会话列表、协作过程、结果编辑和导出 |
| 知识空间 | 列表、详情、上传、Markdown 编辑、Wiki、图谱、检索调试 |
| Notebook | 列表、详情、资料和生成流程 |
| 定时任务 | 任务列表、创建、运行详情、公开运行页 |
| 任务深链 | rwfx、3qfx、xdfx、agentfx、单智能体、会商、行动分区 |
| 任务报告 | 生成、文件卡片、入库确认、宿主通知 |
| 轻应用 | 应用列表、窗口打开、iframe 宿主、动态菜单 |
### 9.3 P2:管理、配置和扩展能力
- 用户、活动、记忆、模型、模型交换与并发配置。
- 智能体、技能、技能上下线、技能地址扫描/预览/替换/回滚。
- 菜单、简洁菜单、按钮、标签、固定问题、推荐问题、提示词前缀。
- 敏感词、排行榜、插件、岗位和岗位角色。
- 业务链、发布广场、私有广场、技能压缩、LLM 指标和 checkpoint 迁移。
- 公开分享、排行榜、会商报告、会商看板和定时任务全屏页面。
- Web App/Site Studio、地图、流程图、图表和沙箱 HTML 预览。
- 微信样式入口、无界嵌入和其他特殊页面壳。
### 9.4 单项验收规则
每个功能项必须记录:
- 入口 URL、角色和前置数据。
- 页面是否正常加载及首个可交互时间。
- 主操作、保存/提交、返回和刷新恢复结果。
- 是否涉及流式、上传、下载、Worker、Canvas、SVG、WebGL 或 iframe。
- Firefox 52 控制台错误和网络失败。
- 样式截图、已知差异和回退行为。
- 验收人、日期、构建 commit 和结果。
路由清单应由路由源码自动提取后与人工场景表合并,新增路由若未进入矩阵则 CI 提示失败。
## 10. 开发阶段
### 阶段 0:基线和可观测性
工作项:
- 固定 Node、pnpm、Firefox 52、Rspack/SWC 候选版本和测试机器信息。
- 记录 Vite legacy 的冷构建、热构建、增量构建、内存峰值和产物体积。
- 保存当前主要页面在现代浏览器和 Firefox 52 的截图、控制台错误及网络日志。
- 自动提取路由清单和动态 import 清单。
- 输出依赖语法扫描报告和浏览器 API 使用清单。
- 建立可重复执行的 benchmark 脚本,结果保存为 JSON 和 Markdown。
退出条件:任何人都能按文档复现当前构建时间和 Firefox 52 白屏/错误现状。
### 阶段 1:Rspack 并行构建骨架
工作项:
- 增加 Rspack 配置、HTML、静态资源、alias、环境变量和 public path。
- 接入内置 SWC,先处理项目源码,再逐步扩展到依赖 allowlist。
- 接入 React、CSS、字体、SVG、图片和动态 import。
- 建立 `dev:firefox52`、`build:firefox52:fast` 和 preview 命令。
- 暂时保留 Vite legacy 命令作为对照和回退。
退出条件:Firefox 52 能通过 Rspack dev server 打开登录页和空工作区,没有语法错误与 chunk 加载错误。
### 阶段 2:运行时与流式链路
工作项:
- 重整统一兼容入口和 polyfill 顺序。
- 完成 XHR 流式适配器并接入核心请求路径。
- 验证取消、超时、错误解析、鉴权头和跨域行为。
- 处理 Web Worker、WebSocket、EventSource 和动态 chunk 加载。
- 完成普通问答端到端测试。
退出条件:普通问答可以连续完成发送、流式接收、停止、重试和刷新恢复。
### 阶段 3:依赖清理和固定补丁
工作项:
- 对首批专项依赖分级。
- 把现有 Vite 字符串补丁迁移为版本固定、fork、alias 或安装后补丁。
- 对 Mermaid、Shiki、Oniguruma、LangSmith、p-queue 等建立单独验收用例。
- 配置最终产物危险语法扫描。
- 为新增依赖增加 CI 扫描。
退出条件:生产产物通过静态扫描,补丁均有版本校验,依赖升级导致补丁失效时构建直接失败并给出清晰错误。
### 阶段 4:CSS 和组件样式兼容
工作项:
- 把现有 PostCSS/Tailwind/Lightning CSS 流程接入 Rspack。
- 建立 `src/styles/firefox52/`,按主题、布局、浮层和业务域拆分手写兼容 CSS,避免继续扩大 `globals.css`。
- 配置核心兼容 CSS 与路由级兼容 CSS 的分包策略,并增加单文件行数、体积和重复规则检查。
- 修复主题颜色、透明度、cascade layer、flex gap 和现代选择器问题。
- 验证 TDesign、Radix、Tailwind 组件和 Portal 浮层。
- 建立 CSS 危险语法扫描与代表页面截图回归。
- 验证各主题和三种目标分辨率。
退出条件:P0 页面和代表组件没有布局塌陷、透明色失效、弹层错位或滚动锁死。
### 阶段 5:全功能逐批清零
按以下批次执行,每批都完成“功能、控制台、网络、样式、刷新恢复”五项检查:
1. 登录、框架、普通问答和文件。
2. 智能体管理与智能体问答。
3. AI 写作、深度研究、公文。
4. 多智能体会商、业务链和报告协作。
5. 知识空间、Notebook、Markdown 编辑器和图谱。
6. 任务深链、任务报告、轻应用和 iframe。
7. 定时任务、管理端和设置页。
8. 公开页面、Site Studio、地图、图表和其他扩展能力。
退出条件:P0、P1 全部通过,P2 完成验证;所有遗留项都有可执行的修复或等价回退方案。
### 阶段 6:构建性能专项优化
工作项:
- 开启并调优 Rspack 持久化缓存和增量构建。
- 开发模式关闭压缩,使用低成本 source map。
- 生产模式使用 SWC minimizer,CSS 使用 Lightning CSS minimizer。
- 缩小 SWC 转换范围,对不兼容依赖使用精确 allowlist。
- 对 Mermaid、Shiki、MapLibre、编辑器、会商等重型模块做路由级拆分。
- 检查重复依赖、barrel export、无效 locale 和大资源。
- 评估 lazy compilation;仅在不破坏 Firefox 52 chunk 加载时启用。
- 将耗时阶段输出到构建 profile,逐项消除长任务。
退出条件:达到第 3.2 节性能门槛,同时功能和样式回归无新增失败。
### 阶段 7:持续集成和离线部署衔接
工作项:
- CI 执行 typecheck、Rspack 构建、危险语法扫描、核心单测和冒烟测试。
- 保存构建时间趋势,超过阈值时提示回归。
- 固定包版本和校验值,保证离线环境可重复安装。
- 离线依赖与发布资产交由 `knowledge-base-bj/deerflow-offline-deployment` 仓库维护,本业务仓库只保留源码和依赖清单。
- 更新启动脚本、部署文档、缓存策略和静态资源响应头。
退出条件:干净环境可以离线安装、构建、启动并通过 Firefox 52 冒烟测试。
### 阶段 8:切换与旧链路清理
工作项:
- 完成最终功能矩阵、视觉报告和性能报告评审。
- 将 Firefox 52 开发和构建命令切换到 Rspack。
- 删除已经被固定补丁替代的 Vite legacy 临时代码。
- 保留一段时间的 Vite 基准脚本,确认稳定后再彻底清理兼容分支中的旧依赖。
退出条件:连续多个提交未出现功能、样式和性能回归,Rspack 成为兼容分支唯一正式构建链。
## 11. 性能测试方法
每组测试至少执行 5 次,取中位数并记录 P95:
1. **冷启动**:删除构建缓存后启动开发服务器,测量到首页可请求时间。
2. **热启动**:保留缓存重启开发服务器。
3. **TSX 增量**:修改一个叶子组件和一个高层布局组件。
4. **CSS 增量**:修改组件样式和全局主题样式。
5. **冷生产构建**:删除 Rspack 缓存和 dist。
6. **热生产构建**:保留缓存、源码不变再次构建。
7. **单文件生产变更**:修改一个组件后重新构建。
记录字段:commit、构建模式、CPU、内存、磁盘、Node/pnpm 版本、总耗时、各阶段耗时、峰值内存、模块数量、chunk 数量、JS/CSS 体积和缓存命中率。
性能比较必须在相同机器和相同后台负载下进行。正式报告同时保留原始日志,避免只记录一次最佳结果。
## 12. 主分支同步策略
兼容分支需要持续接收正常业务开发,采用以下约束:
- 构建和兼容代码集中在独立目录与入口,减少对业务组件的侵入。
- 能通过适配器解决的问题不直接修改业务协议。
- 定期从 `master-zg` 合并新功能,每次合并后自动更新路由和依赖扫描结果。
- 新增路由必须进入功能矩阵;新增依赖必须进入依赖兼容清单。
- 新增流式接口必须使用统一传输层,不能绕过 Firefox 52 XHR 回退。
- 新增主题和全局组件必须进入样式截图集。
- 冲突解决优先保留主分支业务逻辑,再在兼容层补充旧浏览器处理。
## 13. 风险与处理原则
| 风险 | 处理原则 |
|---|---|
| 第三方依赖停止支持旧浏览器 | 固定版本或维护兼容 fork |
| fetch 流式响应不可用 | XHR onprogress 统一回退 |
| Map/WebGL 能力差异 | 降级依赖或提供保持业务信息的二维/静态回退 |
| 编辑器、语法高亮和 Worker 依赖现代能力 | classic worker、主线程回退或兼容版本 |
| CSS 转换后仍存在布局差异 | 兼容 utility、组件基础样式和截图回归 |
| Polyfill 体积过大 | 单目标按需注入,重型功能路由级加载 |
| 兼容补丁随依赖升级失效 | 精确版本校验,失败时终止构建 |
| Rspack 插件兼容不足 | 优先使用内置能力,必要时编写小型 loader/plugin |
| 主分支持续新增功能 | 自动路由、依赖、API 和 CSS 扫描 |
| 旧浏览器自动化不稳定 | 自动冒烟与人工矩阵结合,保留截图和日志证据 |
## 14. 交付物
实施完成后应包含:
- Rspack 开发、快速构建和生产构建配置。
- Firefox 52 统一 polyfill 入口和流式传输适配器。
- 第三方依赖兼容清单及固定补丁。
- CSS 兼容流水线、扫描脚本和视觉基准图。
- 按职责拆分的 Firefox 52 手写兼容 CSS,以及对应的体积和重复规则检查。
- 路由级功能验收矩阵。
- 自动冒烟、危险语法扫描和构建性能脚本。
- 基线报告、最终性能对比报告和已知差异说明。
- 开发运行、离线构建、部署和故障排查文档。
## 15. 最终验收清单
- [x] Firefox 52 开发服务器可直接打开并调试。
- [x] 登录、导航、主题、路由刷新无白屏。
- [x] 普通问答和智能体问答可以稳定流式输出。
- [ ] 上传、下载、Markdown、公式、代码、流程图和 Word 导出通过。
- [ ] AI 写作、研究、会商、知识库、任务深链等 P1 功能全部通过。
- [ ] 管理端、设置、公开页和扩展能力完成 P2 验证。
- [ ] 所有现有主题和目标分辨率完成视觉回归。
- [x] 产物通过危险语法和资源路径扫描。
- [ ] 冷构建、热构建和增量构建达到性能门槛(冷/热生产构建已记录,增量构建仍需单独量化)。
- [ ] 连续构建与长时间运行无 chunk 丢失和内存异常。
- [ ] 新增功能与依赖具备持续兼容检查。
- [ ] 离线环境可以重复安装、构建和部署。
+2 -1
View File
@@ -93,7 +93,8 @@ frontend-web/
| 脚本 | 命令 | 说明 |
|---|---|---|
| `pnpm dev` | `vite` | 启动开发服务器(默认端口 5173;项目实际通过仓库根脚本统一为 5174) |
| `pnpm dev` / `npm run dev` | `rspack serve --config rspack.firefox52.config.mjs --mode development` | 兼容分支默认启动 Firefox 52 开发服务器,端口 5174 |
| `pnpm dev:vite` / `npm run dev:vite` | `vite` | 仅在现代浏览器调试时启动原 Vite 开发服务器 |
| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) |
| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 |
| `pnpm typecheck` | `tsc --noEmit` | 仅做 TS 类型检查,不输出文件 |
@@ -1,49 +0,0 @@
# 麒麟系统 Chromium ~80 真机回归清单
> 背景:Tailwind v4 依赖 `@layer`(Chrome 99+)/`@property`(85+)/`color-mix`、`oklch`(111+),麒麟系统自带的 Chromium 80 多浏览器上整站样式失效。本分支(`feat/tailwind-v3-legacy-chrome`)完成 v4→v3 降级迁移,产物结构性指标已全部达标(详见提交 1607c7fe / 40b02520 / e54abcc6)。本文档供真机验收使用。
## 一、测试准备
1. 在麒麟机器的浏览器地址栏输入 `chrome://version`,记录完整版本号(重点关注是否 ≥84 —— 低于 84 时 flex `gap` 不支持,各处间距会塌陷,需单独反馈)。
2. 部署本分支 `pnpm build` 产物(或开发机 `pnpm preview` 后用麒麟机器访问)。
3. 先跑一次 `pnpm check:legacy-css`(开发机),确认绿色再发版。
## 二、页面巡检(明/暗两套主题各过一遍)
| 页面 | 路径 | 重点检查点 |
|---|---|---|
| 登录/落地页 | `/` | 布局完整、文字阴影(hero 标题)、无裸奔样式 |
| 问答首页 | `/chats/new` | 整体配色、输入框焦点环(回退为内层 outline)、常用提示词栏 |
| 对话流 | 任意会话 | Markdown 渲染(标题/列表/表格/代码块/引用——streamdown 白名单类)、气泡圆角、流式输出 |
| 智能体 | `/page/workspace/agents` 及详情 | 卡片、Tabs、新建页表单、技能穿梭框 |
| 圆桌会商 | roundtable 页 | 左侧步骤条、消息气泡、流程图面板、暗色兜底配色 |
| 任务深链 | `?taskId=&goPath=3qfx/rwfx/xdfx` | 左侧任务卡、目的树、快捷跳转栏(按钮管理配置项) |
| 轻应用/菜单管理/敏感词 | 各管理页 | Tabs、表格、弹窗 |
| 简洁模式 | 外观设置切换 | 无全局导航栏布局、右侧工具箱、引导高亮框 |
| 换肤 | 浅红/暗色/浅蓝/绿/冰蓝/浅黄 | 六套主题各自的 primary/ring 颜色、半透明档(primary/10 等) |
| 大屏(dashboard-agent) | 战情大屏页 | 时间线、图表、暗色文字覆盖 |
| iframe 嵌入 | `?embed=1&theme=dark-blue` | 去全局 chrome、强制主题生效 |
## 三、已知降级点(预期行为,非 bug)
1. **`:has()` 渐进增强**(Chrome <105 不生效):按钮内含图标时内边距略宽、alert 带图标时首列宽度、item 带描述时对齐微差 —— 视觉细节,不影响功能。
2. **input-group 焦点环回退**:聊天输入框的焦点指示从「整框 3px 环」回退为「内层输入控件 2px outline」;校验态从整框环回退为内层红色阴影 —— 仍有明确指示。
3. **`:where()` 圆桌字号缩放**(Chrome <88 不生效):设置页调字号时圆桌页面小字号文本不跟随缩放,基础字号正常。
4. **textarea 自增高**:`field-sizing` 需要 Chrome 123+,旧浏览器走原生 `rows` 行为(已有多行默认值)。
5. **prefers-color-scheme 单块**:产物中 1 处 lightningcss 内部标记(`--lightningcss-light/dark`),无影响。
## 四、重点风险项(出现问题按此排查)
- **整页无样式** → 确认产物是本分支构建(旧 v4 产物在 80 上必然裸奔);查 `@layer`。
- **暗色主题失效** → 查产物 `.dark .dark\:` 前缀选择器是否存在(`pnpm check:legacy-css` 会拦)。
- **弹层错位(下拉/气泡/选择器)** → radix 弹层依赖 `origin-[var(--radix-...)]`,确认类已生成。
- **控制台语法错误/白屏** → JS 侧:确认构建 `target: chrome80` 且 `polyfills.ts` 在 main chunk 首位;记录报错栈反馈。
- **mermaid 流程图 / 地图(maplibre)**:重型库在 80 上的运行时差异,出现异常截图 + 控制台报错反馈,单独处理。
## 五、发版前自检(开发机)
```bash
pnpm build # 构建绿
pnpm check:legacy-css # 产物守卫:@layer/@property/:is() 为 0,color-mix 全部守护
pnpm typecheck # 58 个存量错误 = master 基线(迁移零新增)
```
+39 -5
View File
@@ -12,12 +12,46 @@
root.classList.add("dark");
} catch (e) {}
})();
// Chrome 102 has no AbortSignal.timeout (Chrome 103+). Install before
// any module so LangGraph SDK control requests can set a deadline.
(function () {
try {
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
return;
}
AbortSignal.timeout = function (milliseconds) {
var controller = new AbortController();
var timer = setTimeout(function () {
var reason;
try {
reason = new DOMException("signal timed out", "TimeoutError");
} catch (err) {
reason = new Error("signal timed out");
reason.name = "TimeoutError";
}
try {
controller.abort(reason);
} catch (err) {
controller.abort();
}
}, milliseconds);
controller.signal.addEventListener(
"abort",
function () {
clearTimeout(timer);
},
{ once: true },
);
return controller.signal;
};
} catch (err) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
<script src="/runtime-config.js" data-runtime-config></script>
</head>
<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>
<script type="module" src="/src/main.tsx"></script>
+5061 -553
View File
File diff suppressed because it is too large. Load diff
+49 -10
View File
@@ -4,20 +4,40 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "rspack serve --config rspack.firefox52.config.mjs --mode development",
"dev:vite": "vite",
"dev:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode development",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"build:firefox52": "rspack build --config rspack.firefox52.config.mjs --mode production",
"build:firefox52:fast": "rspack build --config rspack.firefox52.config.mjs --mode production --env fast=true",
"build:firefox52:vite": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode firefox52 --minify false",
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
"build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test",
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"preview:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode production --env preview=true",
"typecheck": "tsc --noEmit",
"test: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:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:legacy-css": "node scripts/check-legacy-css.mjs"
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
"check:firefox52-css": "node scripts/check-firefox52-css.mjs",
"check:firefox52-js": "node scripts/check-firefox52-js.mjs",
"test:firefox52-build": "node --test build/firefox52/legacy-generator-loader.test.cjs build/firefox52/source-compat-loader.test.cjs",
"check:firefox52": "pnpm test:firefox52-build && pnpm test:firefox52-markdown && pnpm check:firefox52-css && pnpm check:firefox52-js"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@@ -71,32 +91,35 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"core-js": "3.45.1",
"core-js": "3.50.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"fflate": "^0.8.3",
"file-saver": "^2.0.5",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"maplibre-gl": "4.7.1",
"marked": "16.4.2",
"mermaid": "^11.15.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"ogl": "^1.0.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"pepjs": "0.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"regenerator-runtime": "0.14.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
@@ -114,26 +137,42 @@
"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",
"@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",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
"vite-plugin-source-identifier": "^1.1.2",
"web-streams-polyfill": "4.3.0"
},
"overrides": {
"@radix-ui/react-slot": "1.3.3"
},
"packageManager": "pnpm@10.26.2",
"browserslist": [
"Chrome >= 80"
],
"packageManager": "pnpm@10.26.2"
"Firefox >= 52"
]
}
-5
View File
@@ -2,11 +2,6 @@ import tailwindcss from "tailwindcss";
import darkDescendant from "./scripts/postcss-dark-descendant.mjs";
import autoprefixer from "autoprefixer";
// Tailwind v3:透明度修饰符(bg-primary/10 等)由 tailwind.config.ts 里的
// --legacy-*-rgb 通道变量直接实现,不再需要 v4 时代的 color-mix 回退插件;
// dark-descendant 把暗色变体的 :is(.dark *) 改写为 Chrome 80 可读的后代形式;
// dev 环境面向开发者的现代 Chrome,产物侧的旧浏览器降级(:is()/逻辑属性/
// 媒体查询 range 语法/厂商前缀)由 vite 的 lightningcss targets 负责。
export default {
plugins: [tailwindcss, darkDescendant, autoprefixer],
};
+335
View File
@@ -0,0 +1,335 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
CopyRspackPlugin,
DefinePlugin,
HtmlRspackPlugin,
LightningCssMinimizerRspackPlugin,
SwcJsMinimizerRspackPlugin,
} from "@rspack/core";
import dotenv from "dotenv";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
const firefox52Target = { firefox: "52" };
function loadViteEnvironment(mode) {
const values = {};
const files = [
".env",
`.env.${mode}`,
".env.local",
`.env.${mode}.local`,
];
for (const filename of files) {
const fullPath = path.join(rootDir, filename);
if (!fs.existsSync(fullPath)) continue;
Object.assign(values, dotenv.parse(fs.readFileSync(fullPath)));
}
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("VITE_") && typeof value === "string") {
values[key] = value;
}
}
return {
...Object.fromEntries(
Object.entries(values).filter(([key]) => key.startsWith("VITE_")),
),
BASE_URL: process.env.VITE_ASSET_BASE ?? "./",
MODE: mode,
DEV: mode === "development",
PROD: mode === "production",
SSR: false,
};
}
function cssLoaders(postcssConfig) {
const loaders = [
{
loader: "builtin:lightningcss-loader",
options: {
targets: firefox52Target,
include: {
logicalProperties: true,
},
minify: false,
},
},
];
if (postcssConfig) {
loaders.push({
loader: "postcss-loader",
options: {
implementation: "postcss",
postcssOptions: {
config: path.join(rootDir, "build", "firefox52", postcssConfig),
},
},
});
}
return loaders;
}
function swcLoader(isDevelopment) {
return {
loader: "builtin:swc-loader",
options: {
detectSyntax: "auto",
isModule: "unknown",
env: {
targets: firefox52Target,
bugfixes: true,
shippedProposals: true,
},
jsc: {
externalHelpers: false,
transform: {
react: {
runtime: "automatic",
development: isDevelopment,
refresh: false,
},
},
},
},
};
}
export default (_env = {}, argv = {}) => {
const mode = argv.mode === "development" ? "development" : "production";
const isDevelopment = mode === "development";
const isFastBuild = _env.fast === true || _env.fast === "true";
const isPreview = _env.preview === true || _env.preview === "true";
const assetBase = isDevelopment || isPreview
? "/"
: process.env.VITE_ASSET_BASE ?? "./";
const buildEnv = loadViteEnvironment(mode);
return {
name: "firefox52",
mode,
context: rootDir,
target: ["web", "es5"],
entry: {
app: path.join(rootDir, "src", "main.tsx"),
},
output: {
path: path.join(rootDir, "dist-firefox52"),
publicPath: assetBase,
filename: isDevelopment ? "assets/[name].js" : "assets/[name].[contenthash:8].js",
chunkFilename: isDevelopment
? "assets/[name].chunk.js"
: "assets/[name].[contenthash:8].chunk.js",
cssFilename: isDevelopment
? "assets/[name].css"
: "assets/[name].[contenthash:8].css",
cssChunkFilename: isDevelopment
? "assets/[name].chunk.css"
: "assets/[name].[contenthash:8].chunk.css",
clean: true,
module: false,
environment: {
arrowFunction: false,
asyncFunction: false,
bigIntLiteral: false,
const: false,
destructuring: false,
dynamicImport: false,
module: false,
optionalChaining: false,
templateLiteral: false,
},
},
cache: {
type: "persistent",
name: `firefox52-${mode}`,
version: "2-tailwind3",
buildDependencies: [
fileURLToPath(import.meta.url),
path.join(rootDir, "package.json"),
path.join(rootDir, "postcss.config.js"),
path.join(rootDir, "tailwind.config.ts"),
path.join(rootDir, "scripts", "postcss-dark-descendant.mjs"),
path.join(rootDir, "scripts", "postcss-firefox52-rgb-vars.mjs"),
path.join(rootDir, "scripts", "postcss-firefox52-where.mjs"),
path.join(rootDir, "build", "firefox52", "source-compat-loader.cjs"),
path.join(rootDir, "build", "firefox52", "legacy-generator-loader.cjs"),
path.join(rootDir, "build", "firefox52", "postcss-tailwind.config.mjs"),
path.join(rootDir, "build", "firefox52", "postcss-compat.config.mjs"),
],
storage: {
type: "filesystem",
directory: path.join(rootDir, ".rspack-cache"),
},
},
experiments: {
css: true,
asyncWebAssembly: true,
},
devtool: isDevelopment ? "cheap-module-source-map" : false,
module: {
rules: [
{
enforce: "pre",
test(resourcePath) {
const id = resourcePath.replaceAll("\\", "/");
return (
/\/p-queue\/dist\/index\.js$/.test(id) ||
/\/mdast-util-gfm-autolink-literal\/lib\/index\.js$/.test(id) ||
id.includes("/oniguruma-parser/") ||
id.includes("/node_modules/mermaid/dist/chunks/") ||
id.includes("/node_modules/web-streams-polyfill/dist/") ||
id.includes("/node_modules/@xyflow/react/") ||
id.includes("/node_modules/react-router/") ||
id.includes("/node_modules/zod/") ||
id.includes("/node_modules/langsmith/")
);
},
use: path.join(rootDir, "build", "firefox52", "source-compat-loader.cjs"),
},
{
oneOf: [
{
resourceQuery: /raw/,
type: "asset/source",
},
{
test(resourcePath) {
const id = resourcePath.replaceAll("\\", "/");
return (
id.includes("/node_modules/@langchain/core/") ||
id.includes("/node_modules/@langchain/langgraph-sdk/") ||
id.includes("/node_modules/langsmith/")
);
},
// Loaders run right-to-left: normalize modules that declare or
// consume async iterators first, then let the fast built-in SWC
// pass handle the complete package consistently.
use: [
swcLoader(isDevelopment),
path.join(rootDir, "build", "firefox52", "legacy-generator-loader.cjs"),
],
},
{
test: /\.(?:js|mjs|cjs|jsx|ts|tsx)$/,
exclude: [
/node_modules[\\/]core-js[\\/]/,
/node_modules[\\/]regenerator-runtime[\\/]/,
/node_modules[\\/]web-streams-polyfill[\\/]/,
],
use: swcLoader(isDevelopment),
},
{
test: /[\\/]src[\\/]styles[\\/]globals\.css$/,
type: "css",
use: cssLoaders("postcss-tailwind.config.mjs"),
},
{
test: /[\\/]src[\\/].*\.css$/,
type: "css",
use: cssLoaders("postcss-compat.config.mjs"),
},
{
test: /\.css$/,
type: "css",
use: cssLoaders(null),
},
{
test: /\.wasm$/,
type: "webassembly/async",
},
{
test: /\.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot)$/i,
type: "asset/resource",
generator: {
filename: "assets/[name].[contenthash:8][ext]",
},
},
],
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".json"],
alias: {
"highlight.js/lib/languages/sql_more$": "highlight.js/lib/languages/sql",
"@/env$": path.join(rootDir, "src", "env.ts"),
"next/navigation$": path.join(rootDir, "src", "shims", "next-navigation.ts"),
"next/link$": path.join(rootDir, "src", "shims", "next-link.tsx"),
"@vite": path.join(rootDir, "src"),
"@": path.join(rootDir, "src"),
},
},
plugins: [
new DefinePlugin({
"import.meta.env": JSON.stringify(buildEnv),
__FIREFOX52_BUILD__: "true",
}),
new HtmlRspackPlugin({
template: path.join(rootDir, "build", "firefox52", "index.html"),
filename: "index.html",
inject: "body",
scriptLoading: "blocking",
minify: false,
}),
new CopyRspackPlugin({
patterns: [
{
from: path.join(rootDir, "public"),
to: ".",
noErrorOnMissing: true,
},
],
}),
],
optimization: {
minimize: !isDevelopment && !isFastBuild,
minimizer: [
new SwcJsMinimizerRspackPlugin({
minimizerOptions: {
ecma: 5,
},
}),
new LightningCssMinimizerRspackPlugin({
minimizerOptions: {
targets: firefox52Target,
include: {
logicalProperties: true,
},
},
}),
],
},
devServer: {
host: "0.0.0.0",
port: Number(process.env.FIREFOX52_PORT || 5174),
hot: false,
liveReload: true,
historyApiFallback: true,
headers: {
"Access-Control-Allow-Origin": "*",
},
static: {
directory: isPreview
? path.join(rootDir, "dist-firefox52")
: path.join(rootDir, "public"),
watch: !isPreview,
},
devMiddleware: {
publicPath: "/",
writeToDisk: isPreview,
},
client: {
overlay: true,
},
},
stats: "errors-warnings",
performance: false,
};
};
@@ -0,0 +1,122 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rounds = Math.max(1, Number(process.env.FIREFOX_BENCHMARK_ROUNDS || 5));
const cold = process.argv.includes("--cold");
const fast = process.argv.includes("--fast");
const command = fast ? "build:firefox52:fast" : "build:firefox52";
const reportDir = path.join(rootDir, ".runtime", "firefox52-benchmark");
function removeWorkspaceDirectory(relativePath) {
const target = path.resolve(rootDir, relativePath);
if (path.dirname(target) !== rootDir) throw new Error(`拒绝清理工作区之外的目录:${target}`);
fs.rmSync(target, { recursive: true, force: true });
}
function runBuild() {
return new Promise((resolve) => {
const startedAt = performance.now();
const executable = process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : "pnpm";
const args = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${command}`] : [command];
const child = spawn(executable, args, {
cwd: rootDir,
env: process.env,
stdio: "inherit",
shell: false,
});
child.on("exit", (code, signal) => {
resolve({
durationMs: Math.round(performance.now() - startedAt),
exitCode: code,
signal,
});
});
child.on("error", (error) => resolve({ durationMs: Math.round(performance.now() - startedAt), exitCode: -1, error: String(error) }));
});
}
function percentile(values, ratio) {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.ceil(ratio * sorted.length) - 1];
}
function artifactStats() {
const assetsDir = path.join(rootDir, "dist-firefox52", "assets");
const stats = { jsFiles: 0, jsBytes: 0, cssFiles: 0, cssBytes: 0 };
if (!fs.existsSync(assetsDir)) return stats;
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const size = fs.statSync(path.join(assetsDir, entry.name)).size;
if (/\.js$/i.test(entry.name)) {
stats.jsFiles += 1;
stats.jsBytes += size;
} else if (/\.css$/i.test(entry.name)) {
stats.cssFiles += 1;
stats.cssBytes += size;
}
}
return stats;
}
if (cold) {
const productionCache = path.resolve(rootDir, ".rspack-cache", "firefox52-production");
if (!productionCache.startsWith(`${path.resolve(rootDir, ".rspack-cache")}${path.sep}`)) {
throw new Error(`拒绝清理预期缓存目录之外的路径:${productionCache}`);
}
fs.rmSync(productionCache, { recursive: true, force: true });
removeWorkspaceDirectory("dist-firefox52");
}
const samples = [];
for (let index = 0; index < rounds; index += 1) {
console.log(`\n[Firefox 52 benchmark] ${index + 1}/${rounds} ${command}`);
const sample = await runBuild();
samples.push(sample);
if (sample.exitCode !== 0) break;
}
const successfulDurations = samples.filter((item) => item.exitCode === 0).map((item) => item.durationMs);
const steadyDurations = successfulDurations.slice(1);
const now = new Date();
const stamp = now.toISOString().replace(/[:.]/g, "-");
const report = {
generatedAt: now.toISOString(),
command: `pnpm ${command}`,
coldCacheBeforeFirstRun: cold,
requestedRounds: rounds,
environment: {
platform: `${os.platform()} ${os.release()} ${os.arch()}`,
cpu: os.cpus()[0]?.model || "unknown",
logicalCpus: os.cpus().length,
totalMemoryBytes: os.totalmem(),
node: process.version,
pnpm: process.env.npm_config_user_agent || "run via pnpm.cmd",
},
samples,
summary: {
successfulRuns: successfulDurations.length,
medianMs: percentile(successfulDurations, 0.5),
p95Ms: percentile(successfulDurations, 0.95),
minMs: successfulDurations.length ? Math.min(...successfulDurations) : null,
maxMs: successfulDurations.length ? Math.max(...successfulDurations) : null,
steadyStateMedianMs: percentile(steadyDurations, 0.5),
steadyStateP95Ms: percentile(steadyDurations, 0.95),
},
artifacts: artifactStats(),
};
fs.mkdirSync(reportDir, { recursive: true });
const jsonPath = path.join(reportDir, `${stamp}.json`);
const markdownPath = path.join(reportDir, `${stamp}.md`);
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
fs.writeFileSync(markdownPath, `# Firefox 52 Rspack 构建基准\n\n- 时间:${report.generatedAt}\n- 命令:\`${report.command}\`\n- 轮次:${samples.length}\n- 全部中位数:${report.summary.medianMs ?? "-"} ms\n- 全部 P95:${report.summary.p95Ms ?? "-"} ms\n- 首轮后的稳态中位数:${report.summary.steadyStateMedianMs ?? "-"} ms\n- 首轮后的稳态 P95:${report.summary.steadyStateP95Ms ?? "-"} ms\n- JS:${report.artifacts.jsFiles} 个 / ${report.artifacts.jsBytes} bytes\n- CSS:${report.artifacts.cssFiles} 个 / ${report.artifacts.cssBytes} bytes\n\n| 轮次 | 耗时(ms) | 退出码 |\n|---:|---:|---:|\n${samples.map((sample, index) => `| ${index + 1} | ${sample.durationMs} | ${sample.exitCode} |`).join("\n")}\n`);
console.log(`\nFirefox 52 benchmark median=${report.summary.medianMs}ms p95=${report.summary.p95Ms}ms steadyMedian=${report.summary.steadyStateMedianMs}ms`);
console.log(`JSON: ${jsonPath}`);
console.log(`Markdown: ${markdownPath}`);
if (successfulDurations.length !== rounds) process.exitCode = 1;
@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
import postcss from "postcss";
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
const assetsDir = path.resolve("dist/assets");
if (!fs.existsSync(assetsDir)) {
throw new Error("dist/assets 不存在,请先运行 pnpm build");
}
const cssFiles = fs
.readdirSync(assetsDir)
.filter((file) => file.endsWith(".css"));
if (cssFiles.length === 0) {
throw new Error("dist/assets 中没有 CSS 构建产物");
}
const failures = [];
let modernDeclarations = 0;
const requiredLegacyFallbacks = [
{
file: "src/components/ui/textarea.tsx",
pattern: /rows=\{rows\s*\?\?\s*3\}/,
description: "Textarea needs a native rows fallback",
},
{
file: "src/components/ai-elements/prompt-input.tsx",
pattern: /<InputGroup className="h-auto min-h-16 flex-col items-stretch">/,
description: "Prompt textarea must not rely on :has() for its layout",
},
{
file: "src/core/utils/mermaid.ts",
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
},
{
file: "src/core/utils/legacy-html-preview.ts",
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
},
{
file: "src/pages/ChatPage.tsx",
pattern: /chat-landing-composer-offset[\s\S]*chat-landing-content-lift/,
description: "Chat landing layout must not rely on Tailwind's individual translate property",
},
{
file: "src/styles/globals.css",
pattern: /\.chat-landing-composer-offset\s*\{\s*position:\s*relative;\s*top:\s*calc\(96px\s*-\s*50vh\);\s*\}/,
description: "Chat landing composer must offset with top, not Tailwind translate",
},
{
file: "src/styles/globals.css",
pattern:
/\.legacy-dialog-viewport\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*center;/,
description: "Dialog content must be flex-centered without Tailwind translate",
},
{
file: "src/components/ui/dialog.tsx",
pattern: /legacy-dialog-viewport[\s\S]*relative grid/,
description: "Dialog panel must not use fixed + translate centering",
},
{
file: "src/styles/globals.css",
pattern:
/\.legacy-dialog-viewport\s*>\s*\[data-slot="dialog-content"\]\s*\{[\s\S]*?min-height:\s*0;[\s\S]*?max-height:\s*100%;/,
description: "Flex-centered dialogs must keep min-height:0 so inner scroll works",
},
{
file: "src/styles/globals.css",
pattern: /\.legacy-floating-layer\s*\{\s*z-index:\s*300;/,
description: "Portaled select/dropdown layers must sit above dialog overlay z-index 200",
},
{
file: "src/components/workspace/skills/skill-file-preview-dialog.tsx",
pattern: /minHeight:[\s\S]*100vh[\s\S]*maxHeight:[\s\S]*100vh/,
description: "Skill file preview must size with vh, not dvh, for Chrome 102",
},
{
file: "src/core/compat/abort-signal.ts",
pattern: /export function installAbortSignalTimeout[\s\S]*AbortSignal/,
description: "LangGraph SDK timeoutMs needs AbortSignal.timeout for Chrome 102",
},
{
file: "src/core/api/api-client.ts",
pattern: /function installLegacyControlTimeout[\s\S]*AbortSignal\.timeout/,
description: "Chat send must skip AbortSignal.timeout when Chrome 102 lacks it",
},
{
file: "index.html",
pattern: /AbortSignal\.timeout = function/,
description: "Chrome 102 needs AbortSignal.timeout before any module loads",
},
{
file: "src/styles/globals.css",
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
description: "Agent chat canvas needs an explicit token background for Chrome 102",
},
{
file: "src/styles/globals.css",
pattern: /@supports not \(translate: 0\)[\s\S]*\.dialog-content-center\s*\{\s*transform:\s*translate\(-50%,\s*-50%\);/,
description: "Dialog centering needs a transform fallback for Chrome 102",
},
];
for (const file of cssFiles) {
const root = postcss.parse(
fs.readFileSync(path.join(assetsDir, file), "utf8"),
{ from: file },
);
root.walkDecls((declaration) => {
if (!unsupportedColor.test(declaration.value)) return;
modernDeclarations += 1;
let ancestor = declaration.parent;
while (ancestor) {
if (
ancestor.type === "atrule" &&
ancestor.name === "supports" &&
unsupportedColor.test(ancestor.params)
) {
return;
}
ancestor = ancestor.parent;
}
let previous = declaration.prev();
while (previous?.type === "comment") previous = previous.prev();
const hasSafeFallback =
!declaration.prop.startsWith("--") &&
previous?.type === "decl" &&
previous.prop === declaration.prop &&
!unsupportedColor.test(previous.value);
if (!hasSafeFallback) {
failures.push(
`${file}:${declaration.source?.start?.line ?? "?"} ${declaration.prop}: ${declaration.value}`,
);
}
});
}
function walk(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(directory, entry.name);
return entry.isDirectory() ? walk(fullPath) : [fullPath];
});
}
for (const file of walk(path.resolve("src"))) {
if (!/\.[jt]sx?$/.test(file)) continue;
const source = fs.readFileSync(file, "utf8");
if (unsupportedColor.test(source)) {
failures.push(
`${path.relative(process.cwd(), file)} contains a runtime-only modern color function`,
);
}
}
for (const fallback of requiredLegacyFallbacks) {
const source = fs.readFileSync(path.resolve(fallback.file), "utf8");
if (!fallback.pattern.test(source)) {
failures.push(`${fallback.file}: ${fallback.description}`);
}
}
if (failures.length > 0) {
console.error("Legacy Chrome CSS compatibility check failed:\n");
console.error(failures.slice(0, 50).join("\n"));
if (failures.length > 50) {
console.error(`... and ${failures.length - 50} more`);
}
process.exit(1);
}
console.log(
`Legacy Chrome (100+) CSS compatibility check passed (${modernDeclarations} guarded modern declarations).`,
);
@@ -1,35 +1,47 @@
import fs from "node:fs";
import fs from "node:fs";
import path from "node:path";
import postcss from "postcss";
/**
* 旧浏览器(麒麟 Chromium ~80)CSS 产物守卫:构建后运行,防止回归。
* Firefox 52 CSS 产物守卫:构建后运行,防止回归。
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式,全库 :is( 为 0;
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式;
* - :where() 由 Firefox 专用 PostCSS 插件展开;
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
* :has()(Chrome 105+)与 :where()(Chrome 88+)按渐进增强放行,不在检查范围。
* :has() 按渐进增强放行,关键输入框布局由显式 class 和 :focus-within 兜底。
*/
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
const assetsDir = path.resolve("dist/assets");
const assetsDir = path.resolve("dist-firefox52/assets");
if (!fs.existsSync(assetsDir)) {
throw new Error("dist/assets 不存在,请先运行 pnpm build");
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/assets 中没有 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");
@@ -37,8 +49,16 @@ for (const file of cssFiles) {
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;
@@ -60,13 +80,19 @@ for (const file of cssFiles) {
});
}
for (const selector of requiredComposerWidths.keys()) {
if (!foundComposerWidths.has(selector)) {
failures.push(`缺少 Firefox 52 输入区宽度回退:${selector}`);
}
}
if (failures.length > 0) {
console.error("旧浏览器 CSS 兼容检查失败:\n");
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(
`旧浏览器(麒麟 Chromium ~80)CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明)。`,
`Firefox 52 CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明,${progressiveHas} 处渐进式 :has())。`,
);
@@ -0,0 +1,48 @@
import fs from "node:fs";
import path from "node:path";
import { parse } from "acorn";
const assetsDir = path.resolve("dist-firefox52/assets");
if (!fs.existsSync(assetsDir)) {
throw new Error("dist-firefox52/assets 不存在,请先运行 pnpm build:firefox52");
}
const jsFiles = fs.readdirSync(assetsDir).filter((file) => file.endsWith(".js"));
if (jsFiles.length === 0) {
throw new Error("dist-firefox52/assets 中没有 JavaScript 构建产物");
}
const failures = [];
let totalBytes = 0;
for (const file of jsFiles) {
const source = fs.readFileSync(path.join(assetsDir, file), "utf8");
totalBytes += Buffer.byteLength(source);
try {
// Firefox 52 implements ES2017 parsing, but not later syntax such as
// optional chaining, BigInt literals, object rest, or dynamic import.
parse(source, {
ecmaVersion: 2017,
sourceType: "script",
allowHashBang: false,
});
} catch (error) {
const location = error.loc ? `${error.loc.line}:${error.loc.column}` : "unknown";
failures.push(`${file}:${location} ${error.message}`);
}
}
if (failures.length > 0) {
console.error("Firefox 52 JavaScript 语法检查失败:\n");
console.error(failures.slice(0, 50).join("\n"));
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
process.exit(1);
}
console.log(
`Firefox 52 JavaScript 语法检查通过(${jsFiles.length} 个文件,${(
totalBytes /
1024 /
1024
).toFixed(1)} MiB)。`,
);
@@ -1,4 +1,4 @@
/**
/**
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
*
@@ -0,0 +1,164 @@
const LEGACY_RGB_NAME = /^--legacy-[a-z0-9-]+-rgb$/i;
const RGB_NAME = /^--[a-z0-9-]+-rgb$/i;
const RGB_CHANNELS = /^\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*$/;
const RGB_CHANNEL_REFERENCE = /^\s*var\(\s*(--[a-z0-9-]+-rgb)(?:\s*,\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?))?\s*\)\s*$/i;
const INHERENT_ALPHA_TOKENS = new Set([
"border",
"input",
"ring",
"sidebar-border",
]);
const ALPHA_MULTIPLIERS = [0, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 1];
function alphaVariableSuffix(multiplier) {
return String(Math.round(multiplier * 1000));
}
function formatAlpha(value) {
return String(Math.round(value * 1_000_000) / 1_000_000);
}
function legacyAlphaToken(variableName) {
const match = variableName.match(/^--legacy-(.+)-rgb$/);
return match && INHERENT_ALPHA_TOKENS.has(match[1]) ? match[1] : null;
}
function normalizeAlpha(variableName, alpha) {
const percentage = alpha.match(/^(\d+(?:\.\d+)?)%$/);
if (percentage) return String(Number(percentage[1]) / 100);
const token = legacyAlphaToken(variableName);
if (!token) return alpha;
const composite = alpha.match(
new RegExp(
`^calc\\(var\\(--${token}-alpha,\\s*1\\)\\s*\\*\\s*(.+)\\)$`,
),
);
if (!composite) return alpha;
const multiplier = composite[1].trim();
const numeric = Number(multiplier);
if (Number.isFinite(numeric)) {
return `var(--ff52-${token}-alpha-${alphaVariableSuffix(numeric)})`;
}
if (/^var\(--tw-[a-z-]+-opacity,\s*1\)$/.test(multiplier)) {
return `var(--ff52-${token}-alpha-${alphaVariableSuffix(1)})`;
}
return alpha;
}
function findClosingParenthesis(value, openingIndex) {
let depth = 0;
for (let index = openingIndex; index < value.length; index += 1) {
if (value[index] === "(") depth += 1;
if (value[index] !== ")") continue;
depth -= 1;
if (depth === 0) return index;
}
return -1;
}
function commaChannelValue(value) {
const channels = value.match(RGB_CHANNELS);
if (channels) return `${channels[1]}, ${channels[2]}, ${channels[3]}`;
const reference = value.match(RGB_CHANNEL_REFERENCE);
if (!reference) return null;
const fallback = reference[2]
? `, ${reference[2]}, ${reference[3]}, ${reference[4]}`
: "";
return `var(${reference[1]}-comma${fallback})`;
}
function rewriteLegacyRgbFunctions(
value,
convertibleNames = { has: (name) => LEGACY_RGB_NAME.test(name) },
) {
let output = "";
let cursor = 0;
while (cursor < value.length) {
const start = value.indexOf("rgb(var(--", cursor);
if (start < 0) return output + value.slice(cursor);
output += value.slice(cursor, start);
const varStart = start + 4;
const varEnd = findClosingParenthesis(value, varStart + 3);
const rgbEnd = findClosingParenthesis(value, start + 3);
if (varEnd < 0 || rgbEnd < 0 || varEnd >= rgbEnd) {
output += value.slice(start, start + 4);
cursor = start + 4;
continue;
}
const variableName = value.slice(varStart + 4, varEnd).trim();
const separator = value.slice(varEnd + 1, rgbEnd);
const slash = separator.indexOf("/");
if (!RGB_NAME.test(variableName) || !convertibleNames.has(variableName) || slash < 0) {
output += value.slice(start, rgbEnd + 1);
cursor = rgbEnd + 1;
continue;
}
const alpha = normalizeAlpha(
variableName,
separator.slice(slash + 1).trim(),
);
output += `rgba(var(${variableName}-comma), ${alpha})`;
cursor = rgbEnd + 1;
}
return output;
}
/**
* Firefox 52 cannot parse CSS Color 4's `rgb(var(--channels) / alpha)` form.
* Keep the space-separated live channels for modern builds, add an equivalent
* comma-separated variable, and rewrite only the Firefox artifact to rgba().
*/
export default function firefox52RgbVars() {
const convertibleNames = new Set();
return {
postcssPlugin: "postcss-firefox52-rgb-vars",
Once(root) {
root.walkDecls((declaration) => {
if (RGB_NAME.test(declaration.prop)) {
const commaValue = commaChannelValue(declaration.value);
if (commaValue) {
convertibleNames.add(declaration.prop);
declaration.cloneAfter({
prop: `${declaration.prop}-comma`,
value: commaValue,
});
const token = legacyAlphaToken(declaration.prop);
if (token) {
const alphaDeclaration = declaration.parent?.nodes?.find(
(node) =>
node.type === "decl" && node.prop === `--${token}-alpha`,
);
const inherentAlpha = Number(alphaDeclaration?.value ?? 1);
for (const multiplier of ALPHA_MULTIPLIERS) {
declaration.cloneAfter({
prop: `--ff52-${token}-alpha-${alphaVariableSuffix(multiplier)}`,
value: formatAlpha(inherentAlpha * multiplier),
});
}
}
}
}
});
},
Declaration(declaration) {
if (!declaration.value.includes("rgb(var(--")) return;
declaration.value = rewriteLegacyRgbFunctions(
declaration.value,
convertibleNames,
);
},
};
}
firefox52RgbVars.postcss = true;
export { rewriteLegacyRgbFunctions };
@@ -0,0 +1,47 @@
import selectorParser from "postcss-selector-parser";
function firstWhere(selector) {
let found;
selector.walkPseudos((pseudo) => {
if (!found && pseudo.value.toLowerCase() === ":where") found = pseudo;
});
return found;
}
function expandWhere(selector) {
const pseudo = firstWhere(selector);
if (!pseudo) return [selector];
const alternatives = pseudo.nodes ?? [];
if (alternatives.length === 0) {
pseudo.remove();
return expandWhere(selector);
}
return alternatives.flatMap((alternative) => {
const clone = selector.clone();
const clonePseudo = firstWhere(clone);
if (!clonePseudo) return [clone];
clonePseudo.replaceWith(...alternative.nodes.map((node) => node.clone()));
return expandWhere(clone);
});
}
/** Expand :where() only in the Firefox 52 build, where the selector is invalid. */
export default function firefox52Where() {
return {
postcssPlugin: "postcss-firefox52-where",
Rule(rule) {
if (!rule.selector.includes(":where(")) return;
rule.selector = selectorParser((root) => {
const selectors = [...root.nodes];
root.removeAll();
for (const selector of selectors) {
for (const expanded of expandWhere(selector)) root.append(expanded);
}
}).processSync(rule.selector);
},
};
}
firefox52Where.postcss = true;
@@ -0,0 +1,70 @@
/**
* Tailwind 4 gracefully falls back from color-mix() to the base semantic
* color, but that turns utilities such as bg-primary/5 into an opaque fill on
* Chrome 109. Replace that fallback with an sRGB-channel based alpha color.
* The modern color-mix() declaration remains in its @supports block.
*/
const LEGACY_RGB_TOKENS = new Set([
"accent",
"background",
"border",
"brand-200",
"card",
"destructive",
"foreground",
"input",
"muted",
"muted-foreground",
"primary",
"primary-foreground",
"ring",
"secondary",
"sidebar-accent",
"sidebar-border",
"sidebar-foreground",
"workspace-brand",
]);
const TRANSPARENT_MIX_RE =
/color-mix\(\s*in\s+[\w-]+\s*,\s*var\(--([\w-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)/i;
function legacyAlphaColor(value) {
const match = value.match(TRANSPARENT_MIX_RE);
if (!match || !LEGACY_RGB_TOKENS.has(match[1])) return null;
const [, token, percentage] = match;
return `rgb(var(--legacy-${token}-rgb) / ${percentage}%)`;
}
export default function legacyColorFallback() {
return {
postcssPlugin: "postcss-legacy-color-fallback",
OnceExit(root) {
root.walkAtRules("supports", (atRule) => {
if (!atRule.params.includes("color-mix")) return;
atRule.walkDecls((modernDeclaration) => {
const fallbackValue = legacyAlphaColor(modernDeclaration.value);
if (!fallbackValue) return;
// Tailwind emits the old-browser declaration immediately before
// the nested @supports block, in the same utility/variant rule.
let fallbackDeclaration = atRule.prev();
while (fallbackDeclaration?.type === "comment") {
fallbackDeclaration = fallbackDeclaration.prev();
}
if (
fallbackDeclaration?.type === "decl" &&
fallbackDeclaration.prop === modernDeclaration.prop
) {
fallbackDeclaration.value = fallbackValue;
}
});
});
},
};
}
legacyColorFallback.postcss = true;
@@ -0,0 +1,35 @@
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.
*/
export default function lightningCssLegacyChrome() {
return {
postcssPlugin: "postcss-lightningcss-legacy-chrome",
OnceExit(root, { result }) {
const filename = root.source?.input.file ?? result.opts.from ?? "style.css";
const output = transform({
filename,
code: Buffer.from(root.toString()),
targets: { chrome: LEGACY_CHROME, firefox: LEGACY_FIREFOX },
minify: false,
});
const compiled = postcss.parse(output.code.toString(), { from: filename });
root.removeAll();
root.append(compiled.nodes);
for (const warning of output.warnings) {
result.warn(warning.message, { plugin: "lightningcss" });
}
},
};
}
lightningCssLegacyChrome.postcss = true;
@@ -0,0 +1,48 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
const password = process.env.FIREFOX_TEST_PASSWORD || "";
async function selectAgentId() {
if (process.env.FIREFOX_TEST_AGENT_ID) return process.env.FIREFOX_TEST_AGENT_ID;
const loginUrl = new URL(`${backendUrl}/api/v1/auth/login/username`);
if (password) loginUrl.searchParams.set("password", password);
const loginResponse = await fetch(loginUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!loginResponse.ok) throw new Error(`测试账号登录失败:HTTP ${loginResponse.status}`);
const auth = await loginResponse.json();
const selectorResponse = await fetch(`${backendUrl}/api/agents?admin=true`, {
headers: { Authorization: `${auth.token_type || "Bearer"} ${auth.access_token}` },
});
if (!selectorResponse.ok) throw new Error(`读取智能体列表失败:HTTP ${selectorResponse.status}`);
const payload = await selectorResponse.json();
const agents = Array.isArray(payload.agents) ? payload.agents : [];
const agent = agents.find((item) => item?.id && item?.model) || agents.find((item) => item?.id);
if (!agent) throw new Error("智能体列表中没有可用于流式验收的智能体");
return String(agent.id);
}
const agentId = await selectAgentId();
const executable = process.execPath;
const child = spawn(executable, [path.join(rootDir, "scripts", "smoke-firefox52.mjs"), "--live-only"], {
cwd: rootDir,
env: {
...process.env,
FIREFOX_LIVE_CHAT_HASH: `#/page/workspace/agents/${encodeURIComponent(agentId)}/chats/new`,
FIREFOX_LIVE_CHAT_NAME: `真实智能体流式问答(${agentId})`,
},
stdio: "inherit",
});
const exitCode = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => resolve(code ?? 1));
});
process.exitCode = exitCode;
@@ -0,0 +1,377 @@
import net from "node:net";
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
const password = process.env.FIREFOX_TEST_PASSWORD || "";
const routeTimeoutMs = Number(process.env.FIREFOX_ROUTE_TIMEOUT_MS || 30_000);
const fullReload = process.env.FIREFOX_FULL_RELOAD === "1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Static routes that can be verified without creating or mutating business
* records. Parameterised workflows need their own test data; this list
* intentionally rejects unexpected auth redirects.
*/
const routes = [
// Public and standalone shells.
["登录页鉴权恢复", `#/login/${encodeURIComponent(username)}`, { redirect: "#/page/workspace/chats/new" }],
["落地页", "#/landing"],
["公开排行榜", "#/public/leaderboard"],
["会商大屏", "#/roundtable-dashboard"],
["Official DeerFlow", "#/official"],
// Workspace routes.
["工作区首页", "#/page/workspace/chats/new"],
["对话列表", "#/page/workspace/chats"],
["项目入口", "#/page/workspace/projects", { redirect: "#/page/strategy/qa/projects" }],
["资料库入口", "#/page/workspace/library"],
["微信新对话", "#/page/workspace/wx/chats/new"],
["微信智能体", "#/page/workspace/wx/agents"],
["微信技能", "#/page/workspace/wx/skills"],
["智能体列表", "#/page/workspace/agents"],
["新建智能体", "#/page/workspace/agents/new"],
["智能体协作网络", "#/page/workspace/agents/orbit"],
["智能体广场", "#/page/workspace/agents/plaza"],
["技能列表", "#/page/workspace/skills"],
["技能进化", "#/page/workspace/skills/evolution"],
["AI 写作", "#/page/workspace/ai-writing"],
["深度研究", "#/page/workspace/deep-research"],
["公文助手", "#/page/workspace/gongwen"],
["新建报告结构", "#/page/workspace/deep-research/structures/new"],
["企业研究", "#/page/workspace/enterprise-research"],
["Notebook 列表", "#/page/workspace/studio/notebooks"],
["多智能体规划", "#/page/workspace/roundtable/planning"],
["8BF 会商", "#/page/workspace/roundtable/8bf"],
["岗位会商", "#/page/workspace/roundtable/position"],
["业务链编辑", "#/page/workspace/roundtable/chains"],
["业务链列表", "#/page/workspace/business-chains"],
["报告协作", "#/page/workspace/report-collaboration"],
["派活链路预览", "#/page/workspace/roundtable/chain-preview"],
["知识空间", "#/page/workspace/knowledge"],
["知识图谱配置回退", "#/page/workspace/knowledge/graph", { redirect: "#/page/workspace/knowledge" }],
["助手知识库配置回退", "#/page/workspace/knowledge/assistant", { redirect: "#/page/workspace/knowledge" }],
["知识检索调试配置回退", "#/page/workspace/knowledge/retrieval-debug", { redirect: "#/page/workspace/knowledge" }],
["Markdown 转 Word", "#/page/workspace/knowledge/markdown-editor"],
["定时任务", "#/page/workspace/scheduled/tasks"],
// Workspace admin/settings surfaces.
["用户管理中心", "#/page/workspace/admin"],
["管理员定时任务", "#/page/workspace/admin/scheduled-tasks"],
["管理员智能体", "#/page/workspace/admin/agents"],
["管理员技能", "#/page/workspace/admin/skills"],
["技能定时", "#/page/workspace/admin/skill-timing"],
["模型交换", "#/page/workspace/admin/model-exchanges"],
["用户排行榜管理", "#/page/workspace/admin/user-leaderboard"],
["用户排行榜设置", "#/page/workspace/admin/user-leaderboard/settings"],
["菜单管理", "#/page/workspace/admin/menu-management"],
["固定问题", "#/page/workspace/admin/fixed-questions"],
["标签管理", "#/page/workspace/admin/tags"],
["插件管理", "#/page/workspace/admin/plugins"],
["岗位管理", "#/page/workspace/admin/positions"],
["岗位角色", "#/page/workspace/admin/position-roles"],
["推荐问题", "#/page/workspace/settings/recommended-questions"],
["提示词前缀", "#/page/workspace/settings/prompt-prefix"],
["私有广场", "#/page/workspace/settings/private-square"],
["模型管理", "#/page/workspace/admin/models"],
["模型并发", "#/page/workspace/settings/model-concurrency"],
["广场发布", "#/page/workspace/settings/publish-squares"],
["技能压缩", "#/page/workspace/settings/skill-compression"],
["LLM 指标", "#/page/workspace/settings/llm-metrics"],
["Checkpoint 迁移", "#/page/workspace/settings/checkpoint-migration"],
// Strategy routes and legacy/full-layout entry points.
["通用问答", "#/page/strategy/qa/general"],
["分析项目", "#/page/strategy/qa/projects"],
["方案项目", "#/page/strategy/qa/projects/solutions", { redirect: "#/page/strategy/qa/projects?type=solution" }],
["问答项目", "#/page/strategy/qa/projects/groups", { redirect: "#/page/strategy/qa/projects?type=qa" }],
["策略选择配置回退", "#/page/strategy/qa/strategy-selection", { redirect: "#/page/strategy/qa/projects" }],
["专家问答", "#/page/strategy/qa/expert-qa"],
["策略多智能体会商", "#/page/strategy/qa/multi-agent-roundtable"],
["策略 8BF 会商", "#/page/strategy/qa/8bf-roundtable"],
["策略岗位会商", "#/page/strategy/qa/position-roundtable"],
["策略深度研究", "#/page/strategy/qa/deep-research"],
["策略公文助手", "#/page/strategy/qa/gongwen"],
["策略新建报告结构", "#/page/strategy/qa/deep-research/structures/new"],
["策略企业研究", "#/page/strategy/qa/enterprise-research"],
["智能体看板", "#/page/strategy/dashboard-agent"],
["任务管理", "#/page/strategy/task-management"],
["三情分析任务", "#/page/strategy/task-management/plan-task-sentiment"],
["任务分析", "#/page/strategy/task-management/plan-task/plan-task-fx"],
["行动分析", "#/page/strategy/task-management/plan-task/plan-task-action"],
["任务三情会商", "#/page/strategy/task-management/plan-task-sentiment/roundtable"],
["任务分析会商", "#/page/strategy/task-management/plan-task/plan-task-fx/roundtable"],
["行动分析会商", "#/page/strategy/task-management/plan-task/plan-task-action/roundtable"],
["网络行为体资源", "#/page/strategy/resource-management/resource-network-actor"],
["知识库资源", "#/page/strategy/resource-management/resource-knowledge-base"],
["评价指标资源", "#/page/strategy/resource-management/resource-evaluation-index"],
["轻应用管理", "#/page/strategy/light-app/management"],
["轻应用列表", "#/page/strategy/light-app/apps"],
["轻应用新建笔记", "#/page/strategy/light-app/new-note"],
["轻应用笔记", "#/page/strategy/light-app/notes"],
["轻应用笔记问答", "#/page/strategy/light-app/note-qa"],
["策略对话", "#/page/strategy/chats"],
["策略智能体", "#/page/strategy/agents"],
["策略智能体广场", "#/page/strategy/agents/plaza"],
["策略新建智能体", "#/page/strategy/agents/new"],
["策略技能", "#/page/strategy/skills"],
["策略技能进化", "#/page/strategy/skills/evolution"],
["业务链映射", "#/page/strategy/business-chain-mapping"],
["策略业务链列表", "#/page/strategy/business-chains"],
["策略业务链编辑", "#/page/strategy/roundtable/chains"],
["策略定时任务", "#/page/strategy/scheduled/tasks"],
["登录白名单", "#/page/strategy/admin/login-whitelist"],
["会商诊断", "#/page/strategy/admin/roundtable-diagnostics"],
["技能地址", "#/page/strategy/admin/skill-addresses"],
["敏感词权限回退", "#/page/strategy/admin/sensitive-words", { redirect: "#/page/workspace/chats/new" }],
["任务按钮", "#/page/strategy/admin/task-buttons"],
["策略管理员定时任务", "#/page/strategy/admin/scheduled-tasks"],
["策略排行榜", "#/page/strategy/user-leaderboard"],
["策略用户活动", "#/page/strategy/admin/user-activity"],
["策略标签", "#/page/strategy/admin/tags"],
["策略岗位", "#/page/strategy/admin/positions"],
// Parallel canvas/notebook/roundtable route trees.
["画布首页", "#/page/canvas"],
["画布 AI 写作", "#/page/canvas/ai-writing"],
["对话版 AI 写作", "#/page/canvas/ai-writing-chat"],
["文章类型", "#/page/canvas/article-types"],
["研究模板", "#/page/canvas/research-templates"],
["新建研究模板", "#/page/canvas/research-templates/new"],
["Notebook AI 写作", "#/page/notebook/ai-writing"],
["独立会商", "#/page/roundtable/planning"],
// Keep isolated/embed routes last because their sessionStorage contract is
// intentionally sticky across in-app navigation.
["iframe 新对话", "#/page/workspace/chats_iframe/new?embed=1&hideJump=1&theme=light", { redirect: "#/page/workspace/chats/new?embed=1&hideJump=1&theme=light&chatMode=full" }],
];
const results = [];
const browserErrors = [];
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
let buffer = Buffer.alloc(0);
let rootActor;
let consoleActor;
let pendingEvaluation;
function send(packet) {
const body = Buffer.from(JSON.stringify(packet));
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
}
function evaluate(expression) {
return new Promise((resolve, reject) => {
pendingEvaluation = { expression, resolve, reject };
send({ to: consoleActor, type: "evaluateJS", text: expression });
});
}
function shouldIgnoreConsoleError(message) {
return (
message.includes("validateDOMNesting") ||
message.includes("init intent failed") ||
message.includes("ResizeObserver loop") ||
message === "[object Object]"
);
}
function formatConsoleArgument(argument) {
if (argument == null || typeof argument !== "object") return String(argument);
const preview = argument.preview;
if (preview?.message) {
return `${preview.name || argument.class || "Error"}: ${preview.message}${preview.stack ? `\n${preview.stack}` : ""}`;
}
if (argument.class === "Error" && argument.ownPropertyLength === 0) return "Error";
if (typeof argument.description === "string") return argument.description;
try {
return JSON.stringify(argument);
} catch {
return String(argument);
}
}
function handle(packet) {
if (packet.applicationType && !rootActor) {
rootActor = packet.from;
send({ to: rootActor, type: "listTabs" });
return;
}
if (packet.tabs && !consoleActor) {
consoleActor = packet.tabs[packet.selected].consoleActor;
send({
to: consoleActor,
type: "startListeners",
listeners: ["PageError", "ConsoleAPI"],
});
return;
}
if (
packet.type === "pageError" &&
packet.pageError &&
packet.pageError.category !== "CSS Parser" &&
packet.pageError.warning !== true
) {
const pageError = packet.pageError;
const errorText = String(pageError.errorMessage || pageError.message || "").trim();
const usefulErrorText = /^Error:\s*$/.test(errorText) ? "" : errorText;
// Firefox 52 emits a duplicate empty promise-rejection packet when an XHR
// owned by the previous document is cancelled during reload. It has no
// stack and no message; meaningful runtime failures carry one or both.
if (!usefulErrorText && !pageError.stacktrace) return;
browserErrors.push(
`PAGE: ${usefulErrorText || JSON.stringify(pageError)}`,
);
return;
}
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
const message = (packet.message.arguments || [])
.map(formatConsoleArgument)
.join(" ") || "error";
if (!shouldIgnoreConsoleError(message)) browserErrors.push(`CONSOLE: ${message}`);
return;
}
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
const pending = pendingEvaluation;
pendingEvaluation = null;
if (packet.exception) pending.reject(new Error(String(packet.exception)));
else pending.resolve(packet.result);
}
}
function consume() {
while (true) {
const colon = buffer.indexOf(58);
if (colon < 0) return;
const length = Number(buffer.subarray(0, colon).toString());
if (buffer.length < colon + 1 + length) return;
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
buffer = buffer.subarray(colon + 1 + length);
handle(packet);
}
}
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
consume();
});
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
while (!consoleActor) await sleep(50);
async function waitFor(expression, timeoutMs = routeTimeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if (await evaluate(expression)) return true;
} catch {
// A full reload can invalidate the previous Firefox console actor for a
// few milliseconds. Retry against the actor once it resumes.
}
await sleep(250);
}
return false;
}
async function installAdminSession() {
const url = new URL(`${backendUrl}/api/v1/auth/login/username`);
if (password) url.searchParams.set("password", password);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!response.ok) {
throw new Error(`测试账号登录失败:HTTP ${response.status}`);
}
const auth = await response.json();
if (auth.system_role !== "admin") {
throw new Error(`测试账号 ${username} 不是管理员,无法验证管理路由`);
}
await evaluate(`(function(){var auth=${JSON.stringify(JSON.stringify(auth))},info=${JSON.stringify(JSON.stringify({ userName: username }))},settings={};try{settings=JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}')}catch(e){}settings.display=settings.display||{};settings.display.layoutMode='full';localStorage.setItem('deerflow.local-settings',JSON.stringify(settings));localStorage.setItem('deerflow.auth',auth);localStorage.setItem('userInfo',info);sessionStorage.setItem('deerflow.auth',auth);sessionStorage.setItem('userInfo',info);return true})()`);
}
async function exerciseVisibleTabs() {
const count = Number(await evaluate(`(function(){return [].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}).length})()`));
let clicks = 0;
for (let index = 0; index < count; index += 1) {
const clicked = await evaluate(`(function(){var items=[].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}),item=items[${index}];if(!item)return false;item.click();return true})()`);
if (!clicked) continue;
clicks += 1;
await sleep(120);
const healthy = await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),text=document.body.innerText||'';return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
if (!healthy) return { pass: false, clicks, detail: `点击第 ${index + 1} 个标签页后进入错误边界` };
}
return { pass: true, clicks };
}
async function navigate(hash, options = {}) {
const expectedHash = options.redirect || hash;
const url = new URL(hash, baseUrl).href;
try {
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)},reload=${JSON.stringify(fullReload)};if(reload){if(location.href!==url)location.href=url;location.reload()}else if(location.href!==url){location.href=url}return true})()`);
} catch {
// Firefox destroys the old console evaluation context during a successful
// full-page reload and occasionally reports that as an opaque exception.
// The route/readiness assertion below is the source of truth.
}
await sleep(700);
const ready = await waitFor(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),hasSurface=document.body.innerText.trim().length>0||!!document.querySelector('iframe,canvas,svg,.cm-editor');return location.hash.indexOf(${JSON.stringify(expectedHash)})===0&&document.readyState==='complete'&&!!root&&rect.width>0&&rect.height>0&&root.innerHTML.length>100&&hasSurface})()`);
if (!ready) {
const state = await evaluate(`(function(){function role(store){try{var value=store.getItem('deerflow.auth');return value?JSON.parse(value).system_role:'none'}catch(e){return 'invalid'}}return location.hash+';local='+role(localStorage)+';session='+role(sessionStorage)+';embed='+sessionStorage.getItem('embed.session')})()`);
return `页面未在 ${Math.round(routeTimeoutMs / 1000)} 秒内完成渲染(${state})`;
}
const healthy = await evaluate(`(function(){var text=document.body.innerText||'';return !/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
return healthy === true ? true : "页面进入错误边界";
}
try {
await installAdminSession();
const routeFilter = process.env.FIREFOX_ROUTE_FILTER
? new RegExp(process.env.FIREFOX_ROUTE_FILTER, "i")
: null;
const selectedRoutes = routeFilter
? routes.filter(([name, hash]) => routeFilter.test(`${name} ${hash}`))
: routes;
if (selectedRoutes.length === 0) {
throw new Error("FIREFOX_ROUTE_FILTER 未匹配到路由");
}
for (const [name, hash, options = {}] of selectedRoutes) {
// Let late callbacks from the previous page settle before assigning
// errors to the next full-page navigation.
await sleep(350);
const errorStart = browserErrors.length;
let detail;
try {
detail = await navigate(hash, options);
if (detail === true) {
const tabResult = await exerciseVisibleTabs();
if (!tabResult.pass) detail = tabResult.detail;
else results.tabClicks = (results.tabClicks || 0) + tabResult.clicks;
}
} catch (error) {
detail = String(error);
}
// Allow asynchronous render errors to arrive before assigning them to the
// next route. Network/API failures are displayed by the page and do not
// become PageError events; uncaught runtime errors still fail the route.
await sleep(150);
const errors = browserErrors
.slice(errorStart)
.filter((message) => !message.startsWith("CONSOLE: Warning:"));
const pass = detail === true && errors.length === 0;
results.push({ name, hash, pass, detail, errors });
console.log(`${pass ? "PASS" : "FAIL"} ${name}${pass ? "" : ` — ${detail}`}`);
for (const error of errors) console.log(` ${error}`);
}
} finally {
socket.end();
}
const failed = results.filter((result) => !result.pass);
console.log(`\nFirefox 52 full route smoke: ${results.length - failed.length}/${results.length} passed`);
console.log(`Non-mutating visible tab interactions: ${results.tabClicks || 0}`);
console.log(`Navigation mode: ${fullReload ? "full reload" : "SPA"}`);
if (failed.length > 0) process.exitCode = 1;
@@ -0,0 +1,163 @@
import net from "node:net";
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const themes = ["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"];
const layouts = ["full", "compact"];
const results = [];
const browserErrors = [];
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
let buffer = Buffer.alloc(0);
let rootActor;
let consoleActor;
let pendingEvaluation;
function send(packet) {
const body = Buffer.from(JSON.stringify(packet));
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
}
function evaluate(expression) {
return new Promise((resolve, reject) => {
pendingEvaluation = { expression, resolve, reject };
send({ to: consoleActor, type: "evaluateJS", text: expression });
});
}
function formatArgument(argument) {
if (argument == null || typeof argument !== "object") return String(argument);
if (argument.preview?.message) return `${argument.preview.name || argument.class || "Error"}: ${argument.preview.message}`;
return argument.description || argument.class || JSON.stringify(argument);
}
function handle(packet) {
if (packet.applicationType && !rootActor) {
rootActor = packet.from;
send({ to: rootActor, type: "listTabs" });
return;
}
if (packet.tabs && !consoleActor) {
consoleActor = packet.tabs[packet.selected].consoleActor;
send({ to: consoleActor, type: "startListeners", listeners: ["PageError", "ConsoleAPI"] });
return;
}
if (packet.type === "pageError" && packet.pageError && packet.pageError.warning !== true && packet.pageError.category !== "CSS Parser") {
const message = String(packet.pageError.errorMessage || packet.pageError.message || "").trim();
if (message || packet.pageError.stacktrace) browserErrors.push(`PAGE: ${message || "unknown page error"}`);
return;
}
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
const message = (packet.message.arguments || []).map(formatArgument).join(" ") || "error";
if (!message.includes("validateDOMNesting") && !message.includes("init intent failed") && !message.includes("ResizeObserver loop") && message !== "[object Object]") {
browserErrors.push(`CONSOLE: ${message}`);
}
return;
}
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
const pending = pendingEvaluation;
pendingEvaluation = null;
if (packet.exception) pending.reject(new Error(packet.exceptionMessage || packet.exception?.description || String(packet.exception)));
else pending.resolve(packet.result);
}
}
function consume() {
while (true) {
const colon = buffer.indexOf(58);
if (colon < 0) return;
const length = Number(buffer.subarray(0, colon).toString());
if (buffer.length < colon + 1 + length) return;
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
buffer = buffer.subarray(colon + 1 + length);
handle(packet);
}
}
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
consume();
});
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
while (!consoleActor) await sleep(50);
async function waitFor(expression, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if (await evaluate(expression)) return true;
} catch {
// A reload briefly invalidates the old evaluation context.
}
await sleep(250);
}
return false;
}
for (const layout of layouts) {
for (const theme of themes) {
const errorStart = browserErrors.length;
let detail = true;
try {
const settings = JSON.stringify({
notification: { enabled: true },
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
artifactDirectoryModeEnabled: false,
contextCompressionEnabled: false,
citationCleanupWords: [],
fontScale: 1,
layoutMode: layout,
},
context: {},
});
const url = new URL("#/page/workspace/chats/new", baseUrl).href;
try {
await evaluate(`(function(){localStorage.setItem('strategy-theme',${JSON.stringify(theme)});localStorage.setItem('strategy-theme-user-set','1');localStorage.setItem('deerflow.local-settings',${JSON.stringify(settings)});sessionStorage.removeItem('embed.session');location.href=${JSON.stringify(url)};location.reload();return true})()`);
} catch {
// A successful full reload can destroy the old console request.
}
// The hash route is identical for every matrix item. Give Firefox time
// to tear down the previous document before accepting a ready DOM.
await sleep(700);
const ready = await waitFor(`(function(){var root=document.getElementById('root'),textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');return location.hash.indexOf('#/page/workspace/chats/new')===0&&document.readyState==='complete'&&!!root&&root.getBoundingClientRect().width>0&&!!textarea&&textarea.getBoundingClientRect().height>0})()`);
if (!ready) {
detail = "页面未在 30 秒内完成渲染";
} else {
const state = await evaluate(`(function(){var html=document.documentElement,root=document.getElementById('root'),textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]'),style=getComputedStyle(html),sidebar=document.querySelector('[data-sidebar="sidebar"],[data-compact-guide="sidebar-nav"]');return JSON.stringify({classes:html.className,dataTheme:html.getAttribute('data-theme')||'',themeMode:html.getAttribute('theme-mode')||'',storedTheme:localStorage.getItem('strategy-theme'),layout:JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}').display.layoutMode,rootWidth:Math.round(root.getBoundingClientRect().width),inputWidth:Math.round(textarea.getBoundingClientRect().width),sidebarVisible:!!sidebar&&sidebar.getBoundingClientRect().width>0,background:style.getPropertyValue('--background').trim(),foreground:style.getPropertyValue('--foreground').trim(),primary:style.getPropertyValue('--primary').trim()})})()`);
const parsed = JSON.parse(state);
const themeApplied = parsed.storedTheme === theme && parsed.classes.split(/\s+/).includes(theme);
const modeApplied = theme === "dark"
? parsed.themeMode === "dark" && parsed.classes.split(/\s+/).includes("dark")
: theme === "light"
? parsed.dataTheme === "" && parsed.themeMode === ""
: parsed.dataTheme === theme && parsed.themeMode === "";
const tokensApplied = parsed.background && parsed.foreground && parsed.primary;
const layoutApplied = parsed.layout === layout;
const dimensionsHealthy = parsed.rootWidth > 0 && parsed.inputWidth > 200;
if (!themeApplied || !modeApplied || !tokensApplied || !layoutApplied || !dimensionsHealthy) detail = state;
}
} catch (error) {
detail = String(error);
}
await sleep(150);
const errors = browserErrors.slice(errorStart);
const pass = detail === true && errors.length === 0;
results.push({ theme, layout, pass, detail, errors });
console.log(`${pass ? "PASS" : "FAIL"} ${layout}/${theme}${pass ? "" : ` — ${detail}`}`);
for (const error of errors) console.log(` ${error}`);
}
}
const viewport = await evaluate(`JSON.stringify({innerWidth:window.innerWidth,innerHeight:window.innerHeight,devicePixelRatio:window.devicePixelRatio,screenWidth:screen.width,screenHeight:screen.height})`);
socket.end();
const failed = results.filter((result) => !result.pass);
console.log(`\nFirefox 52 theme/layout smoke: ${results.length - failed.length}/${results.length} passed`);
console.log(`Viewport: ${viewport}`);
if (failed.length > 0) process.exitCode = 1;
+294
View File
@@ -0,0 +1,294 @@
import net from "node:net";
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
const liveOnly = process.argv.includes("--live-only");
const liveMode = liveOnly || process.argv.includes("--live");
const liveChatHash = process.env.FIREFOX_LIVE_CHAT_HASH || "#/page/workspace/chats/new";
const liveChatName = process.env.FIREFOX_LIVE_CHAT_NAME || "真实流式问答";
const liveThreadPrefix = liveChatHash.replace(/\/new(?:\?.*)?$/, "/");
const smokeFilter = process.env.FIREFOX_SMOKE_FILTER
? new RegExp(process.env.FIREFOX_SMOKE_FILTER, "i")
: null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const routes = [
["新对话", "#/page/workspace/chats/new", `!!document.querySelector('textarea[placeholder="今天我能为你做些什么?"]')`],
["对话", "#/page/workspace/chats", `!!document.querySelector('input[type=search]')`],
["我的项目", "#/page/strategy/qa/projects", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='新建项目'})`],
["AI 写作", "#/page/workspace/ai-writing", `location.hash.indexOf('#/page/workspace/ai-writing')===0&&!!document.querySelector('textarea[placeholder="今天我能为你做些什么?"]')`],
["多智能体规划", "#/page/workspace/roundtable/planning", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='业务链条'})`],
["定时任务", "#/page/workspace/scheduled/tasks", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='新建'})`],
["md 转 Word", "#/page/workspace/knowledge/markdown-editor", `!!document.querySelector('.cm-editor')`],
];
const results = [];
const browserErrors = [];
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
let buffer = Buffer.alloc(0);
let rootActor;
let consoleActor;
let pendingEvaluation;
function send(packet) {
const body = Buffer.from(JSON.stringify(packet));
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
}
function evaluate(expression) {
return new Promise((resolve, reject) => {
pendingEvaluation = { expression, resolve, reject };
send({ to: consoleActor, type: "evaluateJS", text: expression });
});
}
function handle(packet) {
if (packet.applicationType && !rootActor) {
rootActor = packet.from;
send({ to: rootActor, type: "listTabs" });
return;
}
if (packet.tabs && !consoleActor) {
consoleActor = packet.tabs[packet.selected].consoleActor;
send({
to: consoleActor,
type: "startListeners",
listeners: ["PageError", "ConsoleAPI"],
});
return;
}
if (
packet.type === "pageError" &&
packet.pageError?.error &&
packet.pageError.category !== "CSS Parser"
) {
browserErrors.push(`PAGE: ${packet.pageError.errorMessage}`);
return;
}
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
const message = String(packet.message.arguments?.[0] ?? "error");
// Existing scheduled-task cards contain a nested action button. React's
// development-only warning is unrelated to Firefox execution.
if (
!message.includes("validateDOMNesting") &&
!message.includes("init intent failed") &&
message !== "Unhandled promise rejection" &&
message !== "[object Object]"
) {
browserErrors.push(`CONSOLE: ${message}`);
}
return;
}
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
const pending = pendingEvaluation;
pendingEvaluation = null;
if (packet.exception) {
const detail =
packet.exceptionMessage ||
packet.exception?.preview?.message ||
packet.exception?.description ||
packet.exception?.class ||
String(packet.exception);
pending.reject(new Error(String(detail)));
}
else pending.resolve(packet.result);
}
}
function consume() {
while (true) {
const colon = buffer.indexOf(58);
if (colon < 0) return;
const length = Number(buffer.subarray(0, colon).toString());
if (buffer.length < colon + 1 + length) return;
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
buffer = buffer.subarray(colon + 1 + length);
handle(packet);
}
}
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
consume();
});
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
while (!consoleActor) await sleep(50);
async function waitFor(expression, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await evaluate(expression)) return true;
await sleep(250);
}
return false;
}
async function navigate(hash, readyExpression = "true") {
const url = new URL(hash, baseUrl).href;
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)};if(location.href===url)location.reload();else location.href=url;return 'navigate'})()`);
// HashRouter keeps the previous route mounted for a short time after the URL
// changes. Do not mistake that stale DOM for the destination being ready.
await sleep(700);
return waitFor(`(function(){var root=document.getElementById('root');return location.hash.indexOf(${JSON.stringify(hash)})===0&&document.readyState==='complete'&&!!root&&root.innerHTML.length>100&&document.body.innerText.length>20&&(${readyExpression})})()`);
}
const mouseClick = `(function(element){if(!element)return false;['mousemove','mousedown','mouseup','click'].forEach(function(type){element.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type==='mousedown'?1:0}))});return true})`;
const clickText = (text, prefix = false) =>
`(function(){var text=${JSON.stringify(text)},items=[].slice.call(document.querySelectorAll('button,[role=menuitem],[role=tab]'));var element=items.filter(function(item){var value=(item.innerText||'').trim(),rect=item.getBoundingClientRect(),style=getComputedStyle(item);return rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'&&${prefix ? "value.indexOf(text)===0" : "value===text"}})[0];return ${mouseClick}(element)})()`;
const clickTitle = (title) =>
`(function(){var title=${JSON.stringify(title)},element=[].slice.call(document.querySelectorAll('[title]')).filter(function(item){return item.getAttribute('title')===title})[0];return ${mouseClick}(element)})()`;
const clickModelSelector = `(${mouseClick})([].slice.call(document.querySelectorAll('[data-firefox52-smoke="model-selector"]')).filter(function(item){var rect=item.getBoundingClientRect();return rect.width>0&&rect.height>0})[0])`;
const visibleText = (selector, text) =>
`(function(){var text=${JSON.stringify(text)};return [].slice.call(document.querySelectorAll(${JSON.stringify(selector)})).some(function(e){var r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'&&(e.innerText||'').indexOf(text)>=0})})()`;
async function check(name, operation) {
if (smokeFilter && !smokeFilter.test(name)) return;
const errorStart = browserErrors.length;
try {
const detail = await operation();
const errors = browserErrors.slice(errorStart);
results.push({ name, pass: detail === true && errors.length === 0, detail, errors });
} catch (error) {
results.push({ name, pass: false, detail: String(error), errors: browserErrors.slice(errorStart) });
}
}
if (!liveOnly) {
for (const [name, hash, readyExpression] of routes) {
await check(`页面:${name}`, async () => {
if (!(await navigate(hash, readyExpression))) return "页面未在 10 秒内完成渲染";
const healthy = await evaluate(`(function(){var root=document.getElementById('root');return !!root&&root.getBoundingClientRect().width>0&&root.getBoundingClientRect().height>0&&!/something went wrong|页面加载失败|发生错误/i.test(document.body.innerText)})()`);
return healthy || "页面根节点尺寸或错误边界异常";
});
}
await check("设置和更多菜单", async () => {
await navigate("#/page/workspace/chats/new", routes[0][2]);
const settingsTrigger = `((${clickText("设置和更多")}))||((${clickTitle("设置和更多")}))`;
if (!(await waitFor(settingsTrigger, 15_000))) return "未找到入口";
return (await waitFor(visibleText("[role=menu],[data-slot=dropdown-menu-content]", "通知"))) || "菜单未打开";
});
await check("换肤色板", async () => {
await navigate("#/page/workspace/chats/new", routes[0][2]);
const hovered = await evaluate(`(function(){var e=[].slice.call(document.querySelectorAll('[title]')).filter(function(x){return x.getAttribute('title')==='换肤'})[0];if(!e)return false;e.dispatchEvent(new MouseEvent('mouseover',{bubbles:true,view:window}));return true})()`);
if (!hovered) return "未找到换肤入口";
return (await waitFor(`[].slice.call(document.querySelectorAll('button[aria-label]')).filter(function(x){return ['红色','绿色','蓝色','冰川蓝','浅黄色','深蓝色'].indexOf(x.getAttribute('aria-label'))>=0&&x.getBoundingClientRect().width>0}).length>=6`)) || "色板未显示";
});
for (const [name, button, expected] of [
["Agent 选择器", "选择 Agent", "选择 Agent"],
["模型选择器", "__model_selector__", "Model Selector"],
["常用提示词管理", null, "管理常用提示词"],
]) {
await check(name, async () => {
await navigate("#/page/workspace/chats/new", routes[0][2]);
const clicked = await evaluate(
button === "__model_selector__"
? clickModelSelector
: button
? clickText(button)
: clickTitle("管理常用提示词"),
);
if (!clicked) return "未找到入口";
return (await waitFor(visibleText("[role=dialog]", expected))) || "弹窗未打开";
});
}
await check("输入框 Enter 提交", async () => {
await navigate("#/page/workspace/chats/new", routes[0][2]);
const submitted = await evaluate(`(function(){var textarea=document.querySelector('textarea'),form=textarea&&textarea.closest('form');if(!textarea||!form)return false;window.__firefox52SubmitCount=0;form.addEventListener('submit',function(event){event.preventDefault();window.__firefox52SubmitCount+=1},{capture:true,once:true});var setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(textarea,'Firefox 52 Enter smoke test');textarea.dispatchEvent(new Event('input',{bubbles:true}));textarea.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true,cancelable:true}));return true})()`);
if (!submitted) return "未找到输入表单";
return (await waitFor(`window.__firefox52SubmitCount===1`)) || "Enter 未触发表单提交";
});
await check("对话类型筛选", async () => {
await navigate("#/page/workspace/chats", routes[1][2]);
if (!(await evaluate(clickText("多智能体会商", true)))) return "未找到筛选按钮";
await sleep(300);
return (await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect();return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|页面加载失败|发生错误/i.test(document.body.innerText)})()`)) || "筛选后页面异常";
});
await check("项目创建模式", async () => {
await navigate("#/page/strategy/qa/projects", routes[2][2]);
if (!(await evaluate(clickText("新建项目")))) return "未找到新建项目";
return (await waitFor(visibleText("[role=dialog]", "选择创建模式"))) || "创建模式弹窗未打开";
});
await check("AI 写作模型选择", async () => {
await navigate("#/page/workspace/ai-writing", routes[3][2]);
if (!(await evaluate(clickModelSelector))) return "未找到模型入口";
return (await waitFor(visibleText("[role=dialog]", "Model Selector"))) || "模型弹窗未打开";
});
await check("多智能体业务链条", async () => {
await navigate("#/page/workspace/roundtable/planning", routes[4][2]);
if (!(await evaluate(clickText("业务链条")))) return "未找到业务链条入口";
return (await waitFor(visibleText("[role=dialog]", "业务链条"))) || "业务链条弹窗未打开";
});
await check("定时任务新建", async () => {
await navigate("#/page/workspace/scheduled/tasks", routes[5][2]);
if (!(await evaluate(clickText("新建")))) return "未找到新建入口";
return (await waitFor(visibleText("[role=dialog]", "新建定时任务"))) || "新建定时任务弹窗未打开";
});
await check("md 转 Word 编辑器", async () => {
await navigate("#/page/workspace/knowledge/markdown-editor", routes[6][2]);
const ready = await evaluate(`!!document.querySelector('.cm-editor')&&!!document.querySelector('input[placeholder="请输入导出的 Word 标题"]')`);
if (!ready) return "编辑器或标题输入框缺失";
if (!(await evaluate(clickText("重置")))) return "重置按钮不可用";
return true;
});
await check("md 转 Word 实际生成", async () => {
await navigate("#/page/workspace/knowledge/markdown-editor", routes[6][2]);
const installed = await evaluate(`(function(){window.__firefox52Download=null;window.__firefox52DownloadNetwork=[];window.__firefox52DownloadNotices=[];new MutationObserver(function(){[].slice.call(document.querySelectorAll('[data-sonner-toast],[role="alert"],[role="status"]')).forEach(function(item){var value=(item.innerText||item.textContent||'').trim();if(value&&window.__firefox52DownloadNotices.indexOf(value)<0)window.__firefox52DownloadNotices.push(value)})}).observe(document.body,{childList:true,subtree:true});var originalFetch=window.fetch.bind(window);window.fetch=function(){var args=arguments,item={url:String(args[0]&&args[0].url||args[0]||''),status:'pending'};window.__firefox52DownloadNetwork.push(item);return originalFetch.apply(window,args).then(function(response){item.status=response.status;return response},function(error){item.status='error';item.error=String(error);throw error})};var originalCreate=URL.createObjectURL.bind(URL);URL.createObjectURL=function(blob){window.__firefox52Download={blobSize:blob.size,blobType:blob.type,download:''};return originalCreate(blob)};HTMLAnchorElement.prototype.click=function(){var state=window.__firefox52Download||{};state.download=this.download||'';state.href=this.href||'';window.__firefox52Download=state};return true})()`);
if (!installed) return "无法安装下载捕获器";
if (!(await evaluate(clickText("下载 Word")))) return "下载 Word 按钮不可用";
const generated = await waitFor(`(function(){var item=window.__firefox52Download;return !!item&&item.blobSize>0&&/\\.docx$/i.test(item.download)&&item.blobType==='application/vnd.openxmlformats-officedocument.wordprocessingml.document'})()`, 60_000);
if (generated) return true;
return await evaluate(`JSON.stringify({download:window.__firefox52Download||null,network:window.__firefox52DownloadNetwork||[],notices:window.__firefox52DownloadNotices||[]})`);
});
}
if (liveMode) {
await check(liveChatName, async () => {
await navigate(liveChatHash, routes[0][2]);
const marker = `FIREFOX52_STREAM_OK_${Date.now()}`;
const prompt = `请只回复这一行,不要添加其他内容:${marker}`;
const tracing = await evaluate(`(function(){window.__firefox52Network=[];window.__firefox52RuntimeErrors=[];window.__firefox52Notices=[];window.addEventListener('error',function(event){window.__firefox52RuntimeErrors.push(String(event.message||event.error||'error'))});window.addEventListener('unhandledrejection',function(event){window.__firefox52RuntimeErrors.push(String(event.reason||'unhandled rejection'))});new MutationObserver(function(){[].slice.call(document.querySelectorAll('[data-sonner-toast],[role="alert"],[role="status"]')).forEach(function(item){var value=(item.innerText||item.textContent||'').trim();if(value&&window.__firefox52Notices.indexOf(value)<0)window.__firefox52Notices.push(value)})}).observe(document.body,{childList:true,subtree:true});if(!window.__firefox52OriginalFetch){window.__firefox52OriginalFetch=window.fetch;window.fetch=function(){var args=arguments,url=String(args[0]&&args[0].url||args[0]||''),init=args[1]||{};window.__firefox52Network.push({kind:'fetch',method:String(init.method||args[0]&&args[0].method||'GET'),url:url,status:'pending'});var item=window.__firefox52Network[window.__firefox52Network.length-1];return window.__firefox52OriginalFetch.apply(this,args).then(function(response){item.status=response.status;if(item.method==='POST'&&item.url.slice(-12)==='/api/threads'){try{response.clone().text().then(function(body){item.body=body.slice(0,500)},function(error){item.cloneError=String(error)})}catch(error){item.cloneError=String(error)}}return response},function(error){item.status='error';item.error=String(error);throw error})}}if(!window.__firefox52OriginalXhrOpen){window.__firefox52OriginalXhrOpen=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){this.__firefox52Trace={kind:'xhr',method:String(method),url:String(url),status:'pending'};window.__firefox52Network.push(this.__firefox52Trace);this.addEventListener('loadend',function(){this.__firefox52Trace.status=this.status});return window.__firefox52OriginalXhrOpen.apply(this,arguments)}}return true})()`);
if (!tracing) return "无法安装请求追踪器";
const filled = await evaluate(`(function(){var textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');if(!textarea)return false;var setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(textarea,${JSON.stringify(prompt)});textarea.dispatchEvent(new Event('input',{bubbles:true}));return textarea.value===${JSON.stringify(prompt)}})()`);
if (!filled) return "未找到问答输入框";
// Keep Enter compatibility as its own assertion above. The live check
// clicks the real submit control so failures are attributable to the
// backend stream rather than to synthetic keyboard-event differences.
await sleep(300);
const sent = await evaluate(`(function(){var buttons=[].slice.call(document.querySelectorAll('button[aria-label="Submit"]')),button=buttons.filter(function(item){var rect=item.getBoundingClientRect();return rect.width>0&&rect.height>0})[0];if(!button||button.disabled)return false;var form=button.form;if(!form)return false;window.__firefox52SubmitCount=0;form.addEventListener('submit',function(){window.__firefox52SubmitCount+=1},{capture:true});button.click();return true})()`);
if (!sent) return "发送按钮不存在或仍处于禁用状态";
const enteredThread = await waitFor(`location.hash.indexOf(${JSON.stringify(liveThreadPrefix)})===0&&location.hash.indexOf('/new')<0`, 45_000);
if (!enteredThread) {
const diagnostic = await evaluate(`(function(){var textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');return JSON.stringify({hash:location.hash,submitCount:window.__firefox52SubmitCount||0,textarea:textarea&&textarea.value,markerVisible:(document.body.innerText||'').indexOf(${JSON.stringify(marker)})>=0,lastAgent:localStorage.getItem('deerflow.last-agent-id'),pendingAgentMessage:sessionStorage.getItem('pendingAgentMessage'),notices:window.__firefox52Notices||[],runtimeErrors:window.__firefox52RuntimeErrors||[],network:(window.__firefox52Network||[]).slice(-12),text:(document.body.innerText||'').slice(-800)})})()`);
return `发送后未进入会话路由:${diagnostic}`;
}
const streamed = await waitFor(`(function(){var marker=${JSON.stringify(marker)},text=document.body.innerText||'',count=text.split(marker).length-1;return count>=2&&!!document.querySelector('[data-reference-batch-id]')})()`, 180_000);
return streamed || "180 秒内未收到包含校验标记的流式回答";
});
}
socket.end();
for (const result of results) {
const mark = result.pass ? "PASS" : "FAIL";
console.log(`${mark} ${result.name}${result.pass ? "" : ` — ${result.detail}`}`);
for (const error of result.errors) console.log(` ${error}`);
}
const failed = results.filter((result) => !result.pass);
console.log(`\nFirefox 52 smoke: ${results.length - failed.length}/${results.length} passed`);
if (failed.length > 0) process.exitCode = 1;
@@ -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}
/>
);
@@ -34,14 +34,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
import {
DEFAULT_UPLOAD_POLICY,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
import {
DEFAULT_UPLOAD_POLICY,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { imeEnterAction } from "@/lib/ime";
import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai";
import {
@@ -61,10 +61,12 @@ import {
type ChangeEventHandler,
Children,
type ClipboardEventHandler,
type ComponentProps,
createContext,
type FormEvent,
type ComponentProps,
type CompositionEventHandler,
createContext,
type FormEvent,
type FormEventHandler,
forwardRef,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
@@ -128,11 +130,11 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
@@ -148,94 +150,94 @@ const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
uploadPolicy?: UploadPolicy;
}>;
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
uploadPolicy?: UploadPolicy;
}>;
/**
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
uploadPolicy,
children,
}: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
export function PromptInputProvider({
initialInput: initialTextInput = "",
uploadPolicy,
children,
}: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<
(PromptInputFilePart & { id: string })[]
>([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = Array.from(files);
>([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = Array.from(files);
if (incoming.length === 0) {
return;
}
const current = attachmentsRef.current;
const existing = current.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => {
const current = attachmentsRef.current;
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url);
const next = current.filter((f) => f.id !== id);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
attachmentsRef.current = attachmentFiles;
return;
}
const current = attachmentsRef.current;
const existing = current.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => {
const current = attachmentsRef.current;
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url);
const next = current.filter((f) => f.id !== id);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -430,14 +432,14 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
);
}
@@ -483,17 +485,17 @@ export type PromptInputProps = Omit<
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number;
maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: {
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
maxFiles?: number;
maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: {
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
message: string;
}) => void;
onSubmit: (
@@ -509,25 +511,25 @@ export const PromptInput = ({
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
uploadPolicy,
maxFiles,
maxFileSize,
uploadPolicy,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present
}: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
@@ -560,11 +562,11 @@ export const PromptInput = ({
.map((s) => s.trim())
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) {
return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
return f.type.startsWith(prefix);
}
@@ -574,72 +576,72 @@ export const PromptInput = ({
[accept],
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (accepted.length < incoming.length) {
const error = {
code: "accept",
message: "部分文件不符合当前输入框允许的格式。",
} as const;
onError?.(error);
if (!onError) toast.error(error.message);
}
const localPolicy: UploadPolicy = {
...effectiveUploadPolicy,
max_files_per_message:
typeof maxFiles === "number"
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
: effectiveUploadPolicy.max_files_per_message,
groups: Object.fromEntries(
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
key,
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
}
if (validation.accepted.length === 0) return;
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
),
);
},
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (accepted.length < incoming.length) {
const error = {
code: "accept",
message: "部分文件不符合当前输入框允许的格式。",
} as const;
onError?.(error);
if (!onError) toast.error(error.message);
}
const localPolicy: UploadPolicy = {
...effectiveUploadPolicy,
max_files_per_message:
typeof maxFiles === "number"
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
: effectiveUploadPolicy.max_files_per_message,
groups: Object.fromEntries(
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
key,
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
}
if (validation.accepted.length === 0) return;
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
),
);
},
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
(id: string) =>
@@ -827,11 +829,17 @@ export const PromptInput = ({
event.preventDefault();
const form = event.currentTarget;
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
// the DOM before React state catches up, and a send click in that gap
// would otherwise submit the previous pinyin-less value.
const liveText = form.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)?.value;
const text = usingProvider
? controller.textInput.value
? (liveText ?? controller.textInput.value)
: (() => {
const formData = new FormData(form);
return (formData.get("message") as string) || "";
return (formData.get("message") as string) || liveText || "";
})();
// Reset form immediately after capturing text to avoid race condition
@@ -894,7 +902,7 @@ export const PromptInput = ({
const inner = (
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
<input
accept={accept ?? policyAccept}
accept={accept ?? policyAccept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
@@ -909,10 +917,10 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
versions before 105 ignore :has(), retain h-9, and clip the whole
multi-line composer to a single row. */}
<InputGroup className="prompt-input-group h-auto min-h-16 flex-col items-stretch">
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
before 105 ignore them, keep the horizontal flex layout, and
squeeze the textarea beside the footer controls. */}
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
{children}
</InputGroup>
</form>
@@ -934,69 +942,107 @@ export const PromptInputBody = ({
className,
...props
}: PromptInputBodyProps) => (
<div
data-slot="prompt-input-body"
className={cn("contents", className)}
{...props}
/>
<div className={cn("contents", className)} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
onInput,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
export const PromptInputTextarea = ({
onChange,
onInput,
onKeyDown,
onCompositionStart,
onCompositionEnd,
className,
placeholder = "What would you like to know?",
defaultValue,
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const compositionEndedAtRef = useRef(0);
const onChangeRef = useRef(onChange);
const onInputRef = useRef(onInput);
const onKeyDownRef = useRef(onKeyDown);
const onCompositionStartRef = useRef(onCompositionStart);
const onCompositionEndRef = useRef(onCompositionEnd);
const setInputRef = useRef(controller?.textInput.setInput);
const attachmentsRef = useRef(attachments);
onChangeRef.current = onChange;
onInputRef.current = onInput;
onKeyDownRef.current = onKeyDown;
onCompositionStartRef.current = onCompositionStart;
onCompositionEndRef.current = onCompositionEnd;
setInputRef.current = controller?.textInput.setInput;
attachmentsRef.current = attachments;
const publishValue = useCallback((value: string) => {
setInputRef.current?.(value);
}, []);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
// Firefox 102 commits an open IME composition as raw pinyin when the
// textarea's height or overflow changes mid-composition.
if (composingRef.current) return;
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
if (composingRef.current) return;
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isIMEComposing(e, isComposing)) {
const action = imeEnterAction({
isComposing: composingRef.current,
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
keyCode: e.keyCode,
compositionEndedAt: compositionEndedAtRef.current,
now: performance.now(),
});
// Firefox 102 confirms the candidate on this key. preventDefault here
// cancels that confirmation and leaves the pinyin in the box.
if (action === "ignore") {
return;
}
if (e.shiftKey) {
onKeyDownRef.current?.(e);
if (e.defaultPrevented || e.shiftKey) {
return;
}
// The confirming Enter already finished the candidate. Swallow it so
// Firefox does not also insert a newline or send the message.
e.preventDefault();
if (action === "swallow") {
return;
}
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
@@ -1008,22 +1054,59 @@ export const PromptInputTextarea = ({
}
form?.requestSubmit();
return;
}
onKeyDownRef.current?.(e);
if (e.defaultPrevented) return;
if (composingRef.current) return;
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
e.currentTarget.value === "" &&
attachments.files.length > 0
attachmentsRef.current.files.length > 0
) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
const lastAttachment = attachmentsRef.current.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
attachmentsRef.current.remove(lastAttachment.id);
}
}
};
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
composingRef.current = true;
compositionEndedAtRef.current = 0;
onCompositionStartRef.current?.(event);
};
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
onCompositionEndRef.current?.(event);
composingRef.current = false;
compositionEndedAtRef.current = performance.now();
const el = event.currentTarget;
// Firefox 102 dispatches compositionend before the committed candidate is
// visible, and before the input event that carries it. Syncing the
// compositionend value here would write the raw pinyin back over 汉字.
window.setTimeout(() => {
if (el.isConnected) publishValue(el.value);
}, 0);
};
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
// Keep the controlled value identical to the DOM, including the pinyin
// still being composed. Otherwise React restores the previous value at
// the end of the input event and Firefox 102 cannot replace it.
publishValue(event.currentTarget.value);
onChangeRef.current?.(event);
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
const items = event.clipboardData?.items;
@@ -1053,35 +1136,28 @@ export const PromptInputTextarea = ({
}
};
const controlledProps = controller
? {
value: controller.textInput.value,
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onInput={(event) => {
resizeForLegacyChrome(event.currentTarget);
onInput?.(event);
}}
<InputGroupTextarea
{...props}
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onChange={handleChange}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onInput={(event) => {
if (!composingRef.current) {
resizeForLegacyChrome(event.currentTarget);
}
onInputRef.current?.(event);
}}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
{...(controller
? { value: controller.textInput.value }
: { defaultValue })}
/>
);
};
@@ -1127,43 +1203,43 @@ export const PromptInputTools = ({
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
export const PromptInputButton = ({
variant = "ghost",
className,
size,
...props
}: PromptInputButtonProps) => {
return (
<InputGroupButton
className={cn(className)}
size="sm"
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
export const PromptInputButton = forwardRef<
HTMLButtonElement,
PromptInputButtonProps
>(({ variant = "ghost", className, size, ...props }, ref) => (
<InputGroupButton
ref={ref}
className={cn(className)}
size="sm"
type="button"
variant={variant}
{...props}
/>
));
PromptInputButton.displayName = "PromptInputButton";
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<DropdownMenu {...props} />
);
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = forwardRef<
HTMLButtonElement,
PromptInputActionMenuTriggerProps
>(({ className, children, ...props }, ref) => (
<DropdownMenuTrigger asChild>
<PromptInputButton ref={ref} className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
));
PromptInputActionMenuTrigger.displayName = "PromptInputActionMenuTrigger";
export type PromptInputActionMenuContentProps = ComponentProps<
typeof DropdownMenuContent
@@ -1214,9 +1290,9 @@ export const PromptInputSubmit = ({
<InputGroupButton
aria-label="Submit"
className={cn(
"transition-all duration-300",
status === "streaming" &&
"streaming-stop-button bg-primary/10 text-primary border-primary/25 hover:bg-primary/14",
"transition-all duration-300",
status === "streaming" &&
"animate-breathe-stop bg-primary/10 text-primary border-primary/25 shadow-[0_0_0_0_rgba(217,33,33,0.25)] hover:bg-primary/14",
className,
)}
size={size}
@@ -16,6 +16,8 @@ import {
Settings,
ShieldAlert,
ShieldCheck,
ScrollText,
Timer,
User,
Wrench,
} from "lucide-react";
@@ -268,6 +270,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
@@ -587,6 +605,22 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
+36 -31
View File
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
className,
)}
{...props}
@@ -71,37 +71,42 @@ const DialogOverlay = React.forwardRef<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-viewport"
className={cn("legacy-dialog-viewport", overlayClassName)}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
</DialogPortal>
);
});
+270 -270
View File
@@ -1,270 +1,270 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+15 -15
View File
@@ -1,16 +1,16 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
const HoverCardTrigger = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
@@ -48,5 +48,5 @@ const HoverCardContent = React.forwardRef<
});
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
export { HoverCard, HoverCardTrigger, HoverCardContent };
+16 -18
View File
@@ -97,24 +97,22 @@ const inputGroupButtonVariants = cva(
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
const InputGroupButton = React.forwardRef<
HTMLButtonElement,
Omit<React.ComponentPropsWithoutRef<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>
>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => (
<Button
ref={ref}
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
));
InputGroupButton.displayName = "InputGroupButton";
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
@@ -151,8 +151,8 @@
padding: 6px;
background: radial-gradient(
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
rgba(132, 0, 255, 0.8) 0%,
rgba(132, 0, 255, 0.4) 30%,
transparent 60%
);
border-radius: inherit;
@@ -165,7 +165,7 @@
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
opacity: 1;
opacity: var(--glow-intensity);
transition: opacity 0.3s ease;
z-index: 1;
}
+198 -198
View File
@@ -1,198 +1,198 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
className,
)}
{...props}
/>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
className,
)}
{...props}
/>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
File diff suppressed because it is too large. Load diff
+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: {
+32 -32
View File
@@ -1,33 +1,33 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
@@ -66,5 +66,5 @@ const TooltipContent = React.forwardRef<
});
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -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">
@@ -278,6 +278,7 @@ export function AgentGuide({
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const [appDialogOpen, setAppDialogOpen] = useState(false);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
@@ -317,7 +318,32 @@ export function AgentGuide({
setCurrent(-1);
}, [stage]);
if (!eligible) return null;
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
// 先卸掉引导层,关掉后再从当前步恢复。
useEffect(() => {
if (!eligible) return;
const sync = () => {
setAppDialogOpen(
Boolean(document.querySelector('[data-slot="dialog-content"]')),
);
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [eligible]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (
!eligible ||
appDialogOpen ||
current < 0 ||
current >= stepsRef.current.length
) {
return null;
}
return (
<Guide
@@ -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
@@ -19,7 +19,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
@@ -288,7 +288,7 @@ export function ArtifactFileDetail({
headerLeadingActionBare?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
@@ -443,7 +443,14 @@ export function ArtifactFileDetail({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const documentRewriteActive = documentRewrite.isActive;
const generationLockId = useId();
useLayoutEffect(() => {
const owner = `preview:${generationLockId}`;
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
return () => setGeneratingArtifact(owner, null);
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
@@ -1525,7 +1532,7 @@ export function ArtifactFileDetail({
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none !bg-transparent px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
@@ -7,9 +7,11 @@ import {
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) {
@@ -38,6 +40,8 @@ export interface ArtifactsContextType {
select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
/** Register the file being generated; remove this owner's lock on completion. */
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
deselect: () => void;
open: boolean;
@@ -86,6 +90,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const generatingArtifactsRef = useRef(new Map<string, string>());
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
else generatingArtifactsRef.current.delete(owner);
}, []);
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
for (const generating of generatingArtifactsRef.current.values()) {
if (
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
) continue;
if (notify) {
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
id: "artifact-generation-switch-locked",
});
}
return false;
}
return true;
}, []);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
@@ -129,6 +153,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const select = useCallback(
(artifact: string, autoSelect = false) => {
if (!canSwitchArtifact(artifact, !autoSelect)) return;
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
@@ -140,11 +165,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
[canSwitchArtifact, sidebar],
);
const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => {
if (!canSwitchArtifact(artifact, false)) return false;
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
@@ -180,10 +206,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
}
return true;
},
[applyOpen, cancelPendingStreamedOpen, sidebar],
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
);
const deselect = useCallback(() => {
generatingArtifactsRef.current.clear();
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
@@ -224,6 +251,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifactKey,
select,
openStreamedArtifact,
setGeneratingArtifact,
deselect,
};
@@ -2,7 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
@@ -23,6 +23,7 @@ import {
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
isWriteFileInFlight,
} from "@/core/artifacts/write-file-in-flight";
import {
isAutoPreviewableArtifactPath,
@@ -30,7 +31,7 @@ import {
} from "@/core/artifacts/streaming-write-preview";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { resolveRagModeRequested, useLocalSettings } from "@/core/settings";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useIsMobile } from "@/hooks/use-mobile";
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
@@ -152,11 +153,11 @@ const ChatBox: React.FC<{
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled = resolveRagModeRequested({
referenceModeEntryEnabled,
ragModeEnabled: settings.display.ragModeEnabled,
knowledgeSelected: knowledgeReferencesEnabled || !!knowledgeReferences,
});
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled ||
!!knowledgeReferences;
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
@@ -252,8 +253,20 @@ const ChatBox: React.FC<{
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
setGeneratingArtifact,
} = useArtifacts();
// Keep the lock even when the user closes the preview while writing.
const selectedFileGenerating = Boolean(
selectedArtifact && thread.isLoading &&
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
);
useLayoutEffect(() => {
const owner = `chat:${threadId}`;
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
return () => setGeneratingArtifact(owner, null);
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -306,7 +319,7 @@ const ChatBox: React.FC<{
return;
}
openedPreferredArtifactRef.current = preferredKey;
selectArtifact(availablePreferredArtifact);
selectArtifact(availablePreferredArtifact, true);
setArtifactsOpen(true);
setSideTab("document");
// The same canonical report path may be overwritten by a later run while
@@ -574,7 +587,7 @@ const ChatBox: React.FC<{
previewKey,
);
} else {
selectArtifact(automaticTarget);
selectArtifact(automaticTarget, true);
setArtifactsOpen(true);
setSideTab("document");
}
@@ -600,7 +613,7 @@ const ChatBox: React.FC<{
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact);
selectArtifact(firstPreviewableArtifact, true);
}
}
}
@@ -623,8 +636,8 @@ const ChatBox: React.FC<{
writingModeEnabled,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
// the first content bytes — not only when that tool is the last CoT step.
// Live sandbox: open when an in-flight write_file has a usable output path.
// Pathless content stays inline in the CoT, including content-first streams.
// Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo(
@@ -675,7 +688,7 @@ const ChatBox: React.FC<{
return;
}
if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
}
setArtifactsOpen(true);
setSideTab("document");
@@ -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}
@@ -304,7 +304,9 @@ export function CompactModeGuide({
setCurrent(-1);
}, [clearPollTimer, releasePromptDialog]);
if (!eligible) return null;
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
return (
<Guide
@@ -92,7 +92,11 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import { useLocalSettings } from "@/core/settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
@@ -382,13 +386,14 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const ragModeEnabled =
referenceModeEntryEnabled && localSettings.display.ragModeEnabled;
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
setLocalSettings("display", {
ragModeEnabled: !localSettings.display.ragModeEnabled,
});
}, [localSettings.display.ragModeEnabled, setLocalSettings]);
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
@@ -410,6 +415,10 @@ export function InputBox({
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
@@ -434,9 +443,11 @@ export function InputBox({
onSubmit,
onStop,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
status,
threadId,
],
);
@@ -697,7 +708,7 @@ export function InputBox({
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:shadow-md dark:bg-slate-800 dark:border-slate-700",
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
className,
)}
disabled={disabled}
@@ -735,7 +746,7 @@ export function InputBox({
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
<PromptInputActionMenuTrigger className="!px-2" />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments
label={t.inputBox.addAttachments}
@@ -760,8 +771,8 @@ export function InputBox({
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
"!gap-1.5 rounded-full !px-3 shadow-none",
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
context.writing_mode
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
: compact
@@ -789,8 +800,8 @@ export function InputBox({
aria-pressed={artifactDirectoryModeEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
"!gap-1.5 rounded-full !px-3 shadow-none",
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
artifactDirectoryModeEnabled
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
: compact
@@ -816,10 +827,11 @@ export function InputBox({
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={disabled}
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",
"!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
@@ -847,8 +859,8 @@ export function InputBox({
aria-pressed={contextCompressionEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
"!gap-1.5 rounded-full !px-3 shadow-none",
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
contextCompressionEnabled
? "border border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: compact
@@ -884,8 +896,8 @@ export function InputBox({
>
<PromptInputActionMenuTrigger
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
"!gap-1.5 rounded-full border-0 bg-transparent !px-3 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border !px-2.5 !py-1 text-[11px] font-medium",
compact &&
(frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
@@ -1129,7 +1141,7 @@ export function InputBox({
</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]")}>
<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" &&
@@ -1287,11 +1299,12 @@ export function InputBox({
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton
className={cn(
<PromptInputButton
data-firefox52-smoke="model-selector"
className={cn(
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
"rounded-lg border border-transparent bg-gray-50/60 !px-2.5 !py-1 font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<div className="flex min-w-0 flex-col items-start text-left">
@@ -1452,7 +1465,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
return (
<Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton
className={cn("px-2!", className)}
className={cn("!px-2", className)}
onClick={() => attachments.openFileDialog()}
>
<PaperclipIcon className="size-3" />
@@ -1,16 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, Database, SearchCheck } from "lucide-react";
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
import {
useAssistantKnowledgeVisibility,
useUpdateAssistantKnowledgeVisibility,
} from "@/core/system-settings/hooks";
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
// import {
// useAssistantKnowledgeVisibility,
// useUpdateAssistantKnowledgeVisibility,
// } from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadOrdinaryKnowledgeLanding,
@@ -30,7 +30,7 @@ export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
const assistantVisibility = useAssistantKnowledgeVisibility();
// const assistantVisibility = useAssistantKnowledgeVisibility();
const landing = useOrdinaryKnowledgeLanding();
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
@@ -40,11 +40,11 @@ export function KnowledgeProductNav() {
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
const isAdmin = getStoredAuth()?.system_role === "admin";
const assistantAllowed = canAccessAssistantKnowledge(
isAdmin,
assistantVisibility.isError ? undefined : assistantVisibility.data,
);
const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
// const assistantAllowed = canAccessAssistantKnowledge(
// isAdmin,
// assistantVisibility.isError ? undefined : assistantVisibility.data,
// );
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
@@ -54,6 +54,7 @@ export function KnowledgeProductNav() {
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
@@ -64,6 +65,7 @@ export function KnowledgeProductNav() {
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
@@ -71,6 +73,7 @@ export function KnowledgeProductNav() {
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
助手知识库日常展示
<Select
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
@@ -85,6 +88,7 @@ export function KnowledgeProductNav() {
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
*/}
</div>
)}
</div>
@@ -41,12 +41,12 @@ import {
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
@@ -57,9 +57,12 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
buildSkillDisplayMap,
@@ -83,6 +86,7 @@ import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
@@ -90,14 +94,17 @@ export function MessageGroup({
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
}) {
const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@@ -112,7 +119,13 @@ export function MessageGroup({
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills();
const { skills } = useSkills();
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
@@ -271,6 +284,7 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
@@ -781,17 +795,10 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -832,16 +839,40 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
}
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
@@ -873,14 +904,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
if (tool_call.name === "task") {
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
if (
(
tool_call.name === "write_file" ||
@@ -898,7 +952,7 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
File diff suppressed because it is too large. Load diff
@@ -74,6 +74,18 @@ export function SpeedLegend({
}
export function SpeedPill({ metric }: { metric: MessageMetric }) {
if (metric.isPaused) {
return (
<span
className="inline-flex items-center gap-1.5"
title="本段生成停顿,窗口内无新 token"
>
<GaugeIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground">暂停</span>
</span>
);
}
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
@@ -85,7 +97,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
@@ -0,0 +1,207 @@
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RetrievedFile } from "@/core/messages/retrieved-files";
import { getFileIcon } from "@/core/utils/files";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiSourceContext,
resolveLlmWikiSourceKnowledgeBase,
} from "@/strategy-components/api/llmwiki";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ThreadContext } from "./context";
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
// File links belong to the upstream service. Never attach DeerFlow auth headers.
const fetchExternalFile: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "omit" });
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
const [expanded, setExpanded] = useState(false);
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
// Results arrive in retrieval order; keep the newest file outside the fold.
const latest = visible.at(-1);
if (!latest) return null;
const older = visible.slice(0, -1).reverse();
return (
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
{older.length > 0 && (
<CollapsibleTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
{expanded ? "收起" : "展开"}
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
)}
</div>
<ul className="grid gap-2">
<RetrievedFileCard
key={latest.key}
file={latest}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
</ul>
<CollapsibleContent>
<ul className="grid gap-2">
{older.map((file) => (
<RetrievedFileCard
key={file.key}
file={file}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
))}
</ul>
</CollapsibleContent>
</section>
</Collapsible>
);
}
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
file: RetrievedFile;
threadId: string;
isMock?: boolean;
publicShareCode?: string;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [preparingPreview, setPreparingPreview] = useState(false);
const [downloading, setDownloading] = useState(false);
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
const fileUrl = file.kind === "artifact"
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
: file.kind === "external" ? file.url : "";
const resolveKnowledgeTarget = async () => {
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
if (knowledgeTarget) return knowledgeTarget;
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
const target = { ...file.target, knowledgeBaseId };
setKnowledgeTarget(target);
return target;
};
const preview = async () => {
if (preparingPreview) return;
setPreparingPreview(true);
try {
if (file.kind === "knowledge") await resolveKnowledgeTarget();
setPreviewOpen(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件预览失败");
} finally {
setPreparingPreview(false);
}
};
const download = async () => {
if (downloading || file.kind === "external") return;
setDownloading(true);
try {
if (file.kind === "artifact") {
await downloadArtifactFile({
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
});
} else {
const target = await resolveKnowledgeTarget();
let documentId = target.documentId;
if (!documentId && target.chunkId) {
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
documentId = context.document_id || undefined;
}
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
const [blob, document] = await Promise.all([
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
]);
const objectUrl = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = objectUrl;
anchor.download = document?.file_name || file.name;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件下载失败");
} finally {
setDownloading(false);
}
};
return (
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium break-words">{file.name}</div>
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{file.kind === "external" && file.previewUrl ? (
<Button asChild size="sm" variant="ghost">
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
<EyeIcon className="size-4" />预览
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
</Button>
)}
{file.kind === "external" ? (
<Button asChild size="sm" variant="ghost">
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
<DownloadIcon className="size-4" />下载
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
</Button>
)}
</div>
{previewOpen && (file.kind === "knowledge" ? (
<WeKnoraSourceDrawer
open
target={knowledgeTarget || file.target}
title={file.name}
quote={file.quote || ""}
onOpenChange={setPreviewOpen}
/>
) : (
<UploadedFilePreviewDialog
filename={file.name}
fileUrl={fileUrl}
open
onOpenChange={setPreviewOpen}
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
virtualPath={file.kind === "artifact" ? file.path : undefined}
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
onDownload={file.kind === "artifact" ? download : undefined}
downloading={downloading}
/>
))}
</li>
);
}
@@ -1,79 +1,79 @@
import { Skeleton } from "@/components/ui/skeleton";
const STAGGER_MS = 60;
function SkeletonBar({
className,
style,
originRight,
}: {
className?: string;
style?: React.CSSProperties;
originRight?: boolean;
}) {
return (
<div
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
style={{ opacity: 0, ...style }}
>
<Skeleton className="h-full w-full rounded-md" />
</div>
);
}
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-[var(--container-width-lg)] flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
>
<SkeletonBar
className="h-6 w-full"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[80%]"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
<div role="assistant-message" className="flex flex-col gap-2">
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[70%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[60%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[40%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
</div>
);
}
import { Skeleton } from "@/components/ui/skeleton";
const STAGGER_MS = 60;
function SkeletonBar({
className,
style,
originRight,
}: {
className?: string;
style?: React.CSSProperties;
originRight?: boolean;
}) {
return (
<div
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
style={{ opacity: 0, ...style }}
>
<Skeleton className="h-full w-full rounded-md" />
</div>
);
}
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-[var(--container-width-lg)] flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
>
<SkeletonBar
className="h-6 w-full"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[80%]"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
<div role="assistant-message" className="flex flex-col gap-2">
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[70%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[60%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[40%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
GENERATION_IDLE_MS,
ROLLING_WINDOW_MS,
charsToTokensPerSec,
messageHasToolCalls,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
} from "./speed-metrics.ts";
test("rolling window reports the recent rate while characters grow", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 500, chars: 100 },
{ at: 1000, chars: 200 },
];
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
assert.equal(rate, 100);
});
test("rolling window drops to 0 when every sample is older than the window", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 200, chars: 80 },
{ at: 400, chars: 160 },
];
const now = 400 + ROLLING_WINDOW_MS + 50;
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
});
test("rolling window decays toward 0 after a mid-stream pause", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 400, chars: 80 },
];
const live = rollingTokensPerSec(samples, 80, 500, 1000);
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
assert.ok(live > paused);
assert.ok(paused > 0);
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
});
test("usageTokensPerSec is output tokens over the segment duration", () => {
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
assert.equal(usageTokensPerSec(0, 1000), 0);
assert.equal(usageTokensPerSec(10, 0), 0);
});
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
assert.equal(charsToTokensPerSec(100, 1000), 50);
});
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
const base = {
endedAt: null,
isLoading: true,
isLatestGenerating: true,
hasToolCalls: false,
grewThisTick: false,
lastGrewAt: 1000,
now: 1100,
outputTokens: 0,
};
assert.equal(shouldFreezeGeneration(base), false);
assert.equal(
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
true,
);
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
assert.equal(
shouldFreezeGeneration({
...base,
hasToolCalls: true,
grewThisTick: true,
}),
false,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS,
}),
true,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS - 1,
}),
false,
);
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
});
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
assert.equal(messageHasToolCalls({}), false);
assert.equal(
messageHasToolCalls({
tool_calls: [{ id: "1", name: "search" }],
}),
true,
);
assert.equal(
messageHasToolCalls({
additional_kwargs: { tool_calls: [{ id: "1" }] },
}),
true,
);
});
@@ -0,0 +1,108 @@
/**
* Shared tok/s helpers for the chat / roundtable speed pills.
*
* Live rate uses a rolling window with a virtual tail at "now" so a pause
* (no new characters) decays toward 0 instead of freezing on the last two
* samples. Final rate prefers provider `output_tokens` over the segment
* duration, and only after that model-call segment has ended.
*/
export const CHARS_PER_TOKEN = 2;
export const SAMPLE_THROTTLE_MS = 60;
export const ROLLING_WINDOW_MS = 1000;
export const MAX_SPEED_SAMPLES = 256;
/** Idle after last visible growth before we treat this model call as finished. */
export const GENERATION_IDLE_MS = 400;
export type SpeedSample = { at: number; chars: number };
export function estimateTokensFromChars(chars: number): number {
return Math.max(0, chars) / CHARS_PER_TOKEN;
}
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
if (elapsedMs <= 0) return 0;
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
}
export function usageTokensPerSec(
outputTokens: number,
durationMs: number,
): number {
if (outputTokens <= 0 || durationMs <= 0) return 0;
return (outputTokens / durationMs) * 1000;
}
/**
* Instantaneous tok/s over the last `windowMs`.
*
* The tail is always `(now, lastChars)`. If every sample is older than the
* window, the result is 0 — a generation pause must not keep the last
* observed rate pinned on screen.
*/
export function rollingTokensPerSec(
samples: SpeedSample[],
lastChars: number,
now: number,
windowMs: number = ROLLING_WINDOW_MS,
): number {
if (samples.length === 0 || windowMs <= 0) return 0;
const windowStart = now - windowMs;
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
if (headIdx === -1) {
return 0;
}
if (headIdx > 0) {
headIdx -= 1;
}
const head = samples[headIdx];
if (!head) return 0;
const elapsedMs = now - head.at;
if (elapsedMs <= 0) return 0;
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
}
export function pushSpeedSample(
samples: SpeedSample[],
at: number,
chars: number,
): void {
samples.push({ at, chars });
if (samples.length > MAX_SPEED_SAMPLES) {
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
}
}
export function shouldFreezeGeneration(args: {
endedAt: number | null;
isLoading: boolean;
isLatestGenerating: boolean;
hasToolCalls: boolean;
grewThisTick: boolean;
lastGrewAt: number;
now: number;
outputTokens: number;
}): boolean {
if (args.endedAt != null) return false;
if (!args.isLatestGenerating) return true;
if (args.hasToolCalls && !args.grewThisTick) return true;
if (
args.outputTokens > 0 &&
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
) {
return true;
}
return !args.isLoading;
}
export function messageHasToolCalls(message: {
tool_calls?: unknown[] | null;
additional_kwargs?: { tool_calls?: unknown[] } | null;
}): boolean {
const direct = message.tool_calls;
if (Array.isArray(direct) && direct.length > 0) return true;
const extra = message.additional_kwargs?.tool_calls;
return Array.isArray(extra) && extra.length > 0;
}
@@ -158,7 +158,11 @@ function formatBytes(bytes: number): string {
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function useAuthedObjectUrl(
url: string | null,
enabled: boolean,
requestFile: typeof apiFetch = apiFetch,
) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -170,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
}
let cancelled = false;
let created: string | null = null;
const controller = new AbortController();
setLoading(true);
setError(null);
apiFetch(url)
requestFile(url, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@@ -191,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
});
return () => {
cancelled = true;
controller.abort();
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
}, [url, enabled, requestFile]);
return { objectUrl, loading, error };
}
@@ -213,6 +219,9 @@ export function UploadedFilePreviewDialog({
fileUrl,
threadId,
virtualPath,
requestFile = apiFetch,
onDownload,
downloading = false,
open,
onOpenChange,
}: {
@@ -222,6 +231,11 @@ export function UploadedFilePreviewDialog({
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
requestFile?: typeof apiFetch;
/** Authenticated original-file download used by retrieved-file cards. */
onDownload?: () => void | Promise<void>;
downloading?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -242,7 +256,7 @@ export function UploadedFilePreviewDialog({
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
// text / office → fetch text (office reads the converted sibling .md).
const textUrls = useMemo(
@@ -279,6 +293,7 @@ export function UploadedFilePreviewDialog({
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
const controller = new AbortController();
setTextLoading(true);
setTextError(null);
setText(null);
@@ -293,7 +308,7 @@ export function UploadedFilePreviewDialog({
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint);
const response = await apiFetch(endpoint, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
@@ -320,7 +335,8 @@ export function UploadedFilePreviewDialog({
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await apiFetch(textUrl, {
const response = await requestFile(textUrl, {
signal: controller.signal,
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -356,8 +372,9 @@ export function UploadedFilePreviewDialog({
});
return () => {
cancelled = true;
controller.abort();
};
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
@@ -376,21 +393,36 @@ export function UploadedFilePreviewDialog({
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
{onDownload ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3" />
{t.common.download}
</Button>
) : (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
)}
</DialogDescription>
</DialogHeader>
@@ -399,7 +431,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="flex items-center justify-center">
<img
@@ -413,7 +445,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<iframe
src={objectUrl}
@@ -425,7 +457,7 @@ export function UploadedFilePreviewDialog({
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="space-y-3">
{previewMeta &&
@@ -463,7 +495,7 @@ export function UploadedFilePreviewDialog({
</div>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
)}
</div>
</DialogContent>
@@ -471,15 +503,34 @@ export function UploadedFilePreviewDialog({
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
fileUrl: string;
label: string;
onDownload?: () => void | Promise<void>;
downloading?: boolean;
}) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<p>暂不支持在线预览,请下载原文件查看。</p>
{onDownload ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</Button>
) : (
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
)}
</div>
);
}
@@ -7,29 +7,37 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
import {
GENERATION_IDLE_MS,
SAMPLE_THROTTLE_MS,
charsToTokensPerSec,
messageHasToolCalls,
pushSpeedSample,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
type SpeedSample,
} from "./speed-metrics";
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
* Per-AI-message metric: live rolling rate during that model-call segment,
* frozen once the segment ends (not when the whole agent turn ends).
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
* Wall-clock duration of this model-call segment: first visible character
* → last visible character (tool-wait after the call is excluded).
*/
durationMs: number;
/** True while this is the currently streaming message. */
/** True while this message is still generating visible tokens. */
isActive: boolean;
/**
* True while this message is the live generator but the rolling window
* has seen no new characters — the pill should not keep a stale tok/s.
*/
isPaused: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
@@ -45,15 +53,12 @@ export interface MessageMetric {
isTurnFinal: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastGrewAt: number;
samples: SpeedSample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
@@ -82,11 +87,12 @@ function messageText(message: Message): string {
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
* - On every text-length change we push a sample.
* - The segment freezes when this model call ends (next AI message, tool
* calls, usage + idle, or the run stops) so tool-wait is not in the
* denominator.
* - Live rate is a rolling window that decays to 0 on pause.
* - `usage_metadata.output_tokens` only corrects the frozen final value.
*/
export function useMessageMetrics(
messages: Message[],
@@ -104,7 +110,8 @@ export function useMessageMetrics(
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
const grewIds = new Set<string>();
let latestId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
@@ -118,50 +125,62 @@ export function useMessageMetrics(
entry = {
firstSeenAt: now,
endedAt: null,
lastGrewAt: now,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
latestId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
if (grew) {
grewIds.add(id);
entry.lastGrewAt = now;
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
pushSpeedSample(entry.samples, now, text.length);
lastSampleAtRef.current = now;
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const grewThisTick = grewIds.has(id);
if (grewThisTick && entry.endedAt != null && isLoading) {
entry.endedAt = null;
continue;
}
const usage = getUsageMetadata(message);
if (
shouldFreezeGeneration({
endedAt: entry.endedAt,
isLoading,
isLatestGenerating: id === latestId,
hasToolCalls: messageHasToolCalls(message),
grewThisTick,
lastGrewAt: entry.lastGrewAt,
now,
outputTokens: usage?.outputTokens ?? 0,
})
) {
entry.endedAt = entry.lastGrewAt;
}
}
lastActiveIdRef.current = activeId;
lastActiveIdRef.current = latestId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
// Drive a slow tick while streaming so live durations / pause decay refresh.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
@@ -198,45 +217,36 @@ export function useMessageMetrics(
const entry = timingRef.current.get(id);
if (!entry) continue;
const isActive =
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
let isPaused = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
if (entry.endedAt != null) {
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
fromUsage = true;
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
}
} else {
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
isPaused =
isActive &&
tokensPerSec <= 0 &&
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
isPaused,
fromUsage,
isTurnFinal: turnFinalIds.has(id),
});
@@ -0,0 +1,49 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
extractContentFromMessage,
isHiddenFromUIMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
const SUMMARY_MAX_LENGTH = 80;
export type QuestionItem = {
anchorId: string;
summary: string;
};
/** Preserve the raw message index for historical messages without an id. */
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
return `message-${message.id ?? index}`;
}
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
}
/** Index only real, visible questions, using the same visibility policy as chat. */
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}
@@ -11,68 +11,21 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils";
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
import { Tooltip } from "./tooltip";
export { getQuestionAnchorId } from "./question-navigator-data";
/** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
@@ -128,16 +81,7 @@ export function QuestionNavigator({
const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
const visible = questions.length >= MIN_QUESTIONS;
File diff suppressed because it is too large. Load diff
@@ -226,7 +226,9 @@ export function SkillDetailDialog({
style={{
width: "min(48rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(760px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(760px, calc(100vh - 2rem))",
maxHeight: "min(760px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -149,7 +149,9 @@ export function SkillFilePreviewDialog({
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3">
@@ -84,7 +84,7 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
baseTileLoaded = true;
setTileFailed(false);
};
const onError = (event: maplibregl.ErrorEvent) => {
const onError = (event: maplibregl.MapEventType["error"]) => {
const message = event.error?.message ?? "";
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
};
@@ -147,15 +147,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/scheduled/tasks",
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
},
{
id: "workspace-knowledge",
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
// {
// id: "workspace-knowledge",
// label: "知识库管理",
// icon: BookOpen,
// path: "/page/workspace/knowledge",
// match: (pathname) =>
// pathname.startsWith("/page/workspace/knowledge") &&
// !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
// },
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
@@ -20,6 +20,7 @@ import {
SettingsIcon,
SparklesIcon,
TagsIcon,
TimerIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -265,6 +266,22 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<TimerIcon />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollTextIcon />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
@@ -1,23 +1,23 @@
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
"use client";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { GeneralQaSidebar } from "./general-qa-sidebar";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
@@ -30,115 +30,115 @@ export function WorkspaceSidebar({
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
wxCommonAgents ||
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
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",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-[var(--sidebar-width)] border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
if (forceCollapsed) {
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
className,
)}
{...props}
>
<SidebarContent>
<WorkspaceNavChatList />
</SidebarContent>
</Sidebar>
);
}
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
if (compactChrome) {
return (
<div
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",
)}
>
<Sidebar
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-[var(--sidebar-width)] border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
<SidebarContent>
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
全量模式本就不展示。 */}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
}
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent>
<SidebarRail />
</Sidebar>
</div>
);
}
return (
<>
<Sidebar
variant="sidebar"
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
// 非紧凑沿用 icon。
collapsible={compactChrome ? "none" : "icon"}
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<SidebarContent>
<WorkspaceNavChatList
showCollapseTrigger={compactChrome}
compact={compactChrome}
/>
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
全量模式本就不展示。 */}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
);
}
@@ -0,0 +1,28 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ModelExchangeRecord {
id: string;
at: string;
model: string;
user_id: string;
enable_thinking: string;
request_preview: string;
response_preview: string;
request_text: string;
request_body: string;
response_text: string;
response_raw: string;
response_content: string;
response_reasoning: string;
reasoning_field: string;
}
export async function loadModelExchanges(): Promise<ModelExchangeRecord[]> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/model-exchanges`);
if (!response.ok) {
throw new Error("模型收发记录读取失败");
}
const body = (await response.json()) as { records?: ModelExchangeRecord[] };
return body.records ?? [];
}
@@ -0,0 +1,72 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/** One completed sandbox call. Written only after that call returns. */
export interface SkillViewEvent {
at: string;
status: string;
total_ms: number;
lock_wait_ms: number;
refresh_ms: number;
source_entries: number;
target_entries: number;
enabled_count: number;
path_count: number;
tool: string;
command: string;
skill_names: string[];
tool_ms: number;
explanation: {
level: string;
headline: string;
look_at: string;
cause: string;
};
}
export interface SkillViewCheck {
id: string;
title: string;
result: string;
level: string;
look_at: string;
cause: string;
command: string;
skill_names: string[];
}
export interface SkillViewDiagnostics {
cache_enabled: boolean;
reuse_enabled: boolean;
level: string;
conclusion: string;
checks: SkillViewCheck[];
events: SkillViewEvent[];
total: number;
page: number;
page_size: number;
sort: "time" | "duration" | "check";
order: "asc" | "desc";
}
export interface SkillViewQuery {
page: number;
pageSize: number;
sort: "time" | "duration" | "check";
order: "asc" | "desc";
}
/** Polled by the admin timing page. Not an operation log. */
export async function loadSkillViewDiagnostics(query: SkillViewQuery): Promise<SkillViewDiagnostics> {
const params = new URLSearchParams({
page: String(query.page),
page_size: String(query.pageSize),
sort: query.sort,
order: query.order,
});
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/skill-view-diagnostics?${params}`);
if (!response.ok) {
throw new Error("技能耗时记录读取失败");
}
return (await response.json()) as SkillViewDiagnostics;
}
+200 -131
View File
@@ -2,149 +2,218 @@
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth";
import { getLangGraphBaseURL } from "../config";
import { withCsrfHeaders } from "./csrf";
import {
rememberRunStreamCursor,
resolveRunStreamResumeCursor,
type RunStreamCursorStore,
} from "./stream-cursor";
import { sanitizeRunStreamOptions } from "./stream-mode";
import { getAuthorizationHeaderValue } from "../auth";
import {
createDeadlineSignal,
installAbortSignalTimeout,
} from "../compat/abort-signal";
import { getLangGraphBaseURL } from "../config";
function streamEventId(event: unknown): string | undefined {
if (
typeof event === "object" &&
event !== null &&
"id" in event &&
typeof event.id === "string"
) {
return event.id;
}
return undefined;
}
function createCompatibleClient(
isMock?: boolean,
getRunStreamCursors?: () => RunStreamCursorStore,
): LangGraphClient {
import { withCsrfHeaders } from "./csrf";
import {
rememberRunStreamCursor,
resolveRunStreamResumeCursor,
type RunStreamCursorStore,
} from "./stream-cursor";
import { sanitizeRunStreamOptions } from "./stream-mode";
// Chrome 102 has AbortController but not AbortSignal.timeout (Chrome 103+).
// The LangGraph client calls it whenever timeoutMs is set, which is exactly
// the threads.create request fired by pressing Enter. Install the global
// polyfill, and also skip that call if the host object refuses the new method.
installAbortSignalTimeout();
const LEGACY_TIMEOUT_PATCH = Symbol.for("deerflow.legacyControlTimeout");
type ControlFetchOptions = {
timeoutMs?: number | null;
signal?: AbortSignal | null;
};
type PrepareFetchTarget = {
timeoutMs?: number | null;
};
type PrepareFetchOptions = (
this: PrepareFetchTarget,
path: string,
options?: ControlFetchOptions,
) => [URL, RequestInit];
type PrepareFetchOwner = {
prepareFetchOptions: PrepareFetchOptions;
[LEGACY_TIMEOUT_PATCH]?: boolean;
};
function installLegacyControlTimeout(instance: object) {
let owner: PrepareFetchOwner | null = null;
let proto = Object.getPrototypeOf(instance);
while (proto && proto !== Object.prototype) {
if (Object.prototype.hasOwnProperty.call(proto, "prepareFetchOptions")) {
owner = proto as PrepareFetchOwner;
break;
}
proto = Object.getPrototypeOf(proto);
}
if (!owner || owner[LEGACY_TIMEOUT_PATCH]) return;
const original = owner.prepareFetchOptions;
owner.prepareFetchOptions = function (path, options) {
if (typeof AbortSignal.timeout === "function") {
return original.call(this, path, options);
}
const timeoutMs =
options != null &&
Object.prototype.hasOwnProperty.call(options, "timeoutMs")
? options.timeoutMs
: this.timeoutMs;
if (timeoutMs == null) return original.call(this, path, options);
const signal = createDeadlineSignal(timeoutMs, options?.signal);
return original.call(this, path, {
...options,
timeoutMs: null,
signal,
});
};
owner[LEGACY_TIMEOUT_PATCH] = true;
}
function streamEventId(event: unknown): string | undefined {
if (
typeof event === "object" &&
event !== null &&
"id" in event &&
typeof event.id === "string"
) {
return event.id;
}
return undefined;
}
function createCompatibleClient(
isMock?: boolean,
getRunStreamCursors?: () => RunStreamCursorStore,
): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
onRequest(_url, init) {
const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return {
...init,
credentials: init.credentials ?? "include",
headers,
};
},
});
apiUrl: getLangGraphBaseURL(isMock),
// Bound control/history requests so a stalled lookup cannot hold the
// recovery single-flight (or the SDK's post-stream history refresh) open
// forever. The SDK explicitly sets timeoutMs: null for SSE requests.
timeoutMs: 30_000,
onRequest(_url, init) {
const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return {
...init,
credentials: init.credentials ?? "include",
headers,
};
},
});
installLegacyControlTimeout(client.threads);
const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = (async function* (threadId, assistantId, payload) {
// Capture this reader's map. A thread switch replaces the owner's map,
// so late events from an old reader cannot repopulate the new scope.
const readerCursors = getRunStreamCursors?.();
let runId: string | undefined;
let effectiveThreadId = threadId ?? undefined;
const sanitizedPayload = sanitizeRunStreamOptions(payload);
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
const stream = originalRunStream(
client.runs.stream = (async function* (threadId, assistantId, payload) {
// Capture this reader's map. A thread switch replaces the owner's map,
// so late events from an old reader cannot repopulate the new scope.
const readerCursors = getRunStreamCursors?.();
let runId: string | undefined;
let effectiveThreadId = threadId ?? undefined;
const sanitizedPayload = sanitizeRunStreamOptions(payload);
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
const stream = originalRunStream(
threadId,
assistantId,
{
...sanitizedPayload,
onRunCreated(params) {
runId = params.run_id;
effectiveThreadId = params.thread_id ?? effectiveThreadId;
originalOnRunCreated?.(params);
},
},
);
for await (const event of stream) {
if (readerCursors) {
rememberRunStreamCursor(
readerCursors,
effectiveThreadId,
runId,
streamEventId(event),
);
}
yield event;
}
}) as typeof client.runs.stream;
{
...sanitizedPayload,
onRunCreated(params) {
runId = params.run_id;
effectiveThreadId = params.thread_id ?? effectiveThreadId;
originalOnRunCreated?.(params);
},
},
);
for await (const event of stream) {
if (readerCursors) {
rememberRunStreamCursor(
readerCursors,
effectiveThreadId,
runId,
streamEventId(event),
);
}
yield event;
}
}) as typeof client.runs.stream;
const originalJoinStream = client.runs.joinStream.bind(client.runs);
client.runs.joinStream = (async function* (threadId, runId, options) {
const readerCursors = getRunStreamCursors?.();
const sanitizedOptions = sanitizeRunStreamOptions(options);
// The shared client preserves -1. Only a client owned by one message
// accumulator may resume its SDK-initiated reconnectOnMount retry: that
// callback also passes -1 after an error, while retaining previous chunks.
const joinOptions =
readerCursors && threadId != null && !(sanitizedOptions instanceof AbortSignal)
? {
...sanitizedOptions,
lastEventId: resolveRunStreamResumeCursor(
readerCursors,
threadId,
runId,
sanitizedOptions?.lastEventId,
),
}
: sanitizedOptions;
if (threadId != null) {
const lastEventId =
joinOptions instanceof AbortSignal ? undefined : joinOptions?.lastEventId;
console.info("[deer-flow] joining resumable run stream", {
threadId,
runId,
hasLastEventId: Boolean(lastEventId && lastEventId !== "-1"),
replayFromStart: lastEventId === "-1",
});
}
const stream = originalJoinStream(threadId, runId, joinOptions);
for await (const event of stream) {
if (readerCursors) {
rememberRunStreamCursor(
readerCursors,
threadId,
runId,
streamEventId(event),
);
}
yield event;
}
}) as typeof client.runs.joinStream;
client.runs.joinStream = (async function* (threadId, runId, options) {
const readerCursors = getRunStreamCursors?.();
const sanitizedOptions = sanitizeRunStreamOptions(options);
// The shared client preserves -1. Only a client owned by one message
// accumulator may resume its SDK-initiated reconnectOnMount retry: that
// callback also passes -1 after an error, while retaining previous chunks.
const joinOptions =
readerCursors && threadId != null && !(sanitizedOptions instanceof AbortSignal)
? {
...sanitizedOptions,
lastEventId: resolveRunStreamResumeCursor(
readerCursors,
threadId,
runId,
sanitizedOptions?.lastEventId,
),
}
: sanitizedOptions;
if (threadId != null) {
const lastEventId =
joinOptions instanceof AbortSignal ? undefined : joinOptions?.lastEventId;
console.info("[deer-flow] joining resumable run stream", {
threadId,
runId,
hasLastEventId: Boolean(lastEventId && lastEventId !== "-1"),
replayFromStart: lastEventId === "-1",
});
}
const stream = originalJoinStream(threadId, runId, joinOptions);
for await (const event of stream) {
if (readerCursors) {
rememberRunStreamCursor(
readerCursors,
threadId,
runId,
streamEventId(event),
);
}
yield event;
}
}) as typeof client.runs.joinStream;
return client;
}
/**
* Own this scope alongside exactly one SDK message accumulator, never globally.
* Clear it whenever that accumulator clears (a real thread switch). A newly
* mounted hook must create a new scope even when it opens the same live run.
*/
export function createStreamAPIClient(isMock?: boolean) {
let cursors: RunStreamCursorStore = new Map();
return {
client: createCompatibleClient(isMock, () => cursors),
getResumeCursor(threadId: string, runId: string): string | undefined {
return resolveRunStreamResumeCursor(cursors, threadId, runId, undefined);
},
clearResumeCursors() {
cursors = new Map();
},
};
}
/**
* Own this scope alongside exactly one SDK message accumulator, never globally.
* Clear it whenever that accumulator clears (a real thread switch). A newly
* mounted hook must create a new scope even when it opens the same live run.
*/
export function createStreamAPIClient(isMock?: boolean) {
let cursors: RunStreamCursorStore = new Map();
return {
client: createCompatibleClient(isMock, () => cursors),
getResumeCursor(threadId: string, runId: string): string | undefined {
return resolveRunStreamResumeCursor(cursors, threadId, runId, undefined);
},
clearResumeCursors() {
cursors = new Map();
},
};
}
const _clients = new Map<string, LangGraphClient>();
export function getAPIClient(isMock?: boolean): LangGraphClient {
const cacheKey = isMock ? "mock" : "default";
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { setImmediate as nextTurn } from "node:timers/promises";
import test from "node:test";
import { runStreamRecoverySingleFlight, type StreamRecoverySingleFlight } from "../threads/stream-recovery";
import { createStreamAPIClient } from "./api-client";
test("a stalled recovery request times out and releases the single-flight slot", async (t) => {
const timers: Array<{ milliseconds: number; controller: AbortController }> = [];
t.mock.method(AbortSignal, "timeout", (milliseconds: number) => {
const controller = new AbortController();
timers.push({ milliseconds, controller });
return controller.signal;
});
t.mock.method(globalThis, "fetch", async (_input, init) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
assert.ok(signal, "control requests need an abortable deadline");
if (signal.aborted) reject(signal.reason);
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
}));
const { client } = createStreamAPIClient();
const flight: StreamRecoverySingleFlight = { current: null };
const recovery = runStreamRecoverySingleFlight(flight, async () => {
await client.runs.get("thread-1", "run-1");
return "terminal";
});
await nextTurn();
assert.equal(timers.length, 1);
assert.equal(timers[0]!.milliseconds, 30_000);
timers[0]!.controller.abort(new Error("AbortError: control request timed out"));
await assert.rejects(recovery, /control request timed out/);
assert.equal(flight.current, null);
assert.equal(await runStreamRecoverySingleFlight(flight, async () => "rejoined"), "rejoined");
});
test("Chrome 102 can create a thread when AbortSignal.timeout is missing", async (t) => {
const original = AbortSignal.timeout;
Object.defineProperty(AbortSignal, "timeout", {
configurable: true,
writable: true,
value: undefined,
});
t.after(() => {
Object.defineProperty(AbortSignal, "timeout", {
configurable: true,
writable: true,
value: original,
});
});
const caller = new AbortController();
t.mock.method(globalThis, "fetch", async (input, init) => {
const url = String(input);
if (url.includes("/runs/stream") || url.includes("/join")) {
return new Response('event: values\ndata: {"messages":[]}\n\n', {
headers: { "Content-Type": "text/event-stream" },
});
}
const signal = init?.signal;
assert.ok(signal, "control requests still need an abortable deadline");
assert.notEqual(signal, caller.signal);
assert.equal(signal.aborted, false);
caller.abort();
assert.equal(signal.aborted, true);
return new Response(JSON.stringify({ thread_id: "thread-1" }), {
headers: { "Content-Type": "application/json" },
});
});
const { client } = createStreamAPIClient();
const created = await client.threads.create({ signal: caller.signal });
assert.equal(created.thread_id, "thread-1");
const frames: Array<{ event: string }> = [];
for await (const frame of client.runs.stream("thread-1", "lead_agent", {
streamMode: ["values"],
streamIdleReconnect: 0,
})) {
frames.push(frame);
}
assert.equal(frames.length, 1);
assert.equal(frames[0]!.event, "values");
});
test("the control timeout does not interrupt long-running SSE create or rejoin streams", async (t) => {
t.mock.method(AbortSignal, "timeout", () => assert.fail("SSE must not receive the control deadline"));
t.mock.method(globalThis, "fetch", async () => new Response(
'event: values\ndata: {"messages":[]}\n\n',
{ headers: { "Content-Type": "text/event-stream", "Content-Location": "/threads/thread-1/runs/run-1" } },
));
const { client } = createStreamAPIClient();
for (const stream of [
client.runs.stream("thread-1", "lead_agent", { streamMode: ["values"], streamIdleReconnect: 0 }),
client.runs.joinStream("thread-1", "run-1", { streamIdleReconnect: 0 }),
]) {
const frames: Array<{ event: string }> = [];
for await (const frame of stream) frames.push(frame);
assert.equal(frames.length, 1);
assert.equal(frames[0]!.event, "values");
}
});
@@ -43,6 +43,10 @@ export interface ArtifactLibraryResponse {
offset: number;
limit: number;
items: ArtifactLibraryItem[];
syncing?: boolean;
syncStatus?: string | null;
indexedAt?: string | null;
syncError?: string | null;
}
export async function fetchArtifactLibrary({
@@ -87,6 +91,10 @@ export async function fetchArtifactLibrary({
total?: number;
offset?: number;
limit?: number;
syncing?: boolean;
sync_status?: string | null;
indexed_at?: string | null;
sync_error?: string | null;
items?: Array<{
thread_id: string;
thread_title: string;
@@ -111,6 +119,10 @@ export async function fetchArtifactLibrary({
total: data.total ?? 0,
offset: data.offset ?? offset,
limit: data.limit ?? limit,
syncing: data.syncing ?? false,
syncStatus: data.sync_status ?? null,
indexedAt: data.indexed_at ?? null,
syncError: data.sync_error ?? null,
items: (data.items ?? []).map((item) => ({
threadId: item.thread_id,
threadTitle: item.thread_title,
@@ -70,8 +70,14 @@ function matchingToolArgChunks(
const byId = toolCall.id
? chunks.filter((chunk) => chunk?.id && chunk.id === toolCall.id)
: [];
const anchoredIndex =
toolCall.index ?? byId.find((chunk) => chunk?.index != null)?.index;
const selected = byId.length
? byId
? chunks.filter((chunk) => {
if (chunk?.id) return chunk.id === toolCall.id;
if (anchoredIndex == null || chunk?.index !== anchoredIndex) return false;
return !toolCall.name || !chunk?.name || chunk.name === toolCall.name;
})
: chunks.filter((chunk) => {
if (toolCall.name && chunk?.name && chunk.name !== toolCall.name) {
return false;
@@ -91,12 +97,15 @@ function matchingToolArgChunks(
}
export function resolveStreamingWriteFileArgs(
message: { tool_call_chunks?: readonly ToolCallChunkLike[] },
message: object,
toolCall: ToolCallLike,
): { path: string; content: string } {
const chunks = (message as {
tool_call_chunks?: readonly ToolCallChunkLike[];
}).tool_call_chunks;
const direct = asRecord(toolCall.args);
const fromChunks = parsePartialJsonObject(
accumulateToolArgChunks(matchingToolArgChunks(message.tool_call_chunks, toolCall)),
accumulateToolArgChunks(matchingToolArgChunks(chunks, toolCall)),
);
return {
path: pickLongerString(direct?.path, fromChunks?.path),
@@ -32,9 +32,9 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
test("isAutoPreviewableArtifactPath limits automatic previews to outputs", () => {
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), false);
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
});
@@ -39,8 +39,17 @@ export function provisionalWriteFilePath(content: string): string {
export function isProvisionalStreamingArtifactPath(path: string): boolean {
const pathname =
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
return PROVISIONAL_WRITE_FILE_NAMES.has(pathname.replace(/^\/+/, ""));
}
/** A missing/partial tool path is inline text, never a synthesized file. */
export function namedStreamingWriteFilePath(path: string): string | null {
const normalized = normalizeStreamingArtifactPath(path);
return normalized &&
hasCompleteArtifactExtension(normalized) &&
!isProvisionalStreamingArtifactPath(normalized)
? normalized
: null;
}
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
@@ -66,9 +75,6 @@ export function isArtifactApiPath(path: string): boolean {
* an empty sandbox before the real output exists.
*/
export function isAutoPreviewableArtifactPath(path: string): boolean {
if (isProvisionalStreamingArtifactPath(path)) {
return true;
}
const normalized = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.replace(/^\/+/, "")
@@ -327,7 +327,7 @@ test("writing mode still previews follow-up-turn writes after a later user messa
assert.equal(preview?.previewKey, "m-edit:w2");
});
test("normal mode keeps previewing pathless streaming drafts", () => {
test("normal mode leaves pathless streaming drafts in the timeline", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
@@ -337,8 +337,7 @@ test("normal mode keeps previewing pathless streaming drafts", () => {
],
},
]);
assert.ok(preview);
assert.match(preview?.artifactPath ?? "", /\.md/);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
@@ -466,6 +465,40 @@ test("latestInFlightWriteFilePreview opens from snapshot-style tool_call_chunks"
assert.match(preview?.artifactPath ?? "", /page\.html/);
});
test("artifact renderer deltas use the native write_file live-preview contract", () => {
const message = {
id: "m-renderer",
type: "ai",
tool_call_chunks: [
{
id: "call_deerflow_wfbridge_1",
name: "write_file",
index: 0,
args:
'{"description":"写报告","path":"/mnt/user-data/outputs/report.md","append":"__deerflow_stream_not_committed__","content":"# 标',
},
{
index: 0,
args: "题\\n正文正在生成",
},
],
};
const preview = latestInFlightWriteFilePreview([message]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m-renderer:call_deerflow_wfbridge_1");
assert.match(preview?.artifactPath ?? "", /report\.md/);
const args = resolveStreamingWriteFileArgs(message, {
id: "call_deerflow_wfbridge_1",
name: "write_file",
index: 0,
args: {},
});
assert.equal(args.path, "/mnt/user-data/outputs/report.md");
assert.equal(args.content, "# 标题\n正文正在生成");
});
test("resolveStreamingWriteFileArgs grows content from snapshot chunks", () => {
const args = resolveStreamingWriteFileArgs(
{
@@ -7,11 +7,10 @@ import {
} from "./parse-partial-json";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
namedStreamingWriteFilePath,
} from "./streaming-write-preview";
export { resolveStreamingWriteFileArgs } from "./parse-partial-json";
@@ -250,16 +249,7 @@ export function latestInFlightWriteFilePreview(
if (hasToolResult(thread, toolCall.id)) {
continue;
}
const rawPath = toolCall.path;
const content = toolCall.content;
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: content.trim()
? provisionalWriteFilePath(content)
: null;
const sourcePath = namedStreamingWriteFilePath(toolCall.path);
if (!sourcePath) {
continue;
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
createAbortSignalTimeout,
createDeadlineSignal,
installAbortSignalTimeout,
} from "./abort-signal.ts";
test("does not replace a native AbortSignal.timeout implementation", () => {
const original = () => new AbortController().signal;
const host = { timeout: original };
assert.equal(installAbortSignalTimeout(host), false);
assert.equal(host.timeout, original);
});
test("installs timeout on a host that is missing it", () => {
const host: { timeout?: (milliseconds: number) => AbortSignal } = {};
assert.equal(installAbortSignalTimeout(host), true);
assert.equal(typeof host.timeout, "function");
});
test("cancelling the caller drops the deadline immediately", () => {
const caller = new AbortController();
const signal = createDeadlineSignal(60_000, caller.signal);
assert.equal(signal.aborted, false);
caller.abort();
assert.equal(signal.aborted, true);
});
test("the polyfill aborts after the requested delay", async () => {
const timeout = createAbortSignalTimeout();
const signal = timeout(15);
assert.equal(signal.aborted, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(signal.aborted, true);
assert.equal((signal.reason as Error | undefined)?.name, "TimeoutError");
});
@@ -0,0 +1,74 @@
/**
* Chrome 102 (the Win7-era baseline this app still ships) has AbortController
* but not AbortSignal.timeout, which landed in Chrome 103. The LangGraph SDK
* calls AbortSignal.timeout() whenever Client.timeoutMs is set, so a send
* would throw TypeError before the request left the browser.
*/
type TimeoutHost = {
timeout?: (milliseconds: number) => AbortSignal;
};
function timeoutError(): Error {
try {
return new DOMException("signal timed out", "TimeoutError");
} catch {
const error = new Error("signal timed out");
error.name = "TimeoutError";
return error;
}
}
function abortWith(controller: AbortController, reason: unknown) {
try {
controller.abort(reason);
} catch {
controller.abort();
}
}
export function createDeadlineSignal(
milliseconds: number,
caller?: AbortSignal | null,
): AbortSignal {
if (caller?.aborted) return caller;
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
throw new TypeError(
"Failed to execute 'timeout' on 'AbortSignal': parameter 1 is not a valid timeout.",
);
}
const controller = new AbortController();
const timer = setTimeout(() => abortWith(controller, timeoutError()), milliseconds);
const stop = () => clearTimeout(timer);
controller.signal.addEventListener("abort", stop, { once: true });
caller?.addEventListener(
"abort",
() => {
stop();
abortWith(controller, caller.reason);
},
{ once: true },
);
return controller.signal;
}
export function createAbortSignalTimeout(): (milliseconds: number) => AbortSignal {
return function timeout(milliseconds: number): AbortSignal {
return createDeadlineSignal(milliseconds);
};
}
export function installAbortSignalTimeout(
host: TimeoutHost = AbortSignal,
): boolean {
if (typeof host.timeout === "function") return false;
host.timeout = createAbortSignalTimeout();
return true;
}
try {
installAbortSignalTimeout();
} catch {
// Some Chrome 102 hosts reject new static methods. The chat client then
// supplies its own deadline instead of calling AbortSignal.timeout.
}
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error The Node type-stripping runner requires the explicit suffix.
import {
hasUsableNativeResponseStream,
isFirefox52StreamingRequest,
parseXhrResponseHeaders,
} from "./firefox52-fetch.ts";
test("requires pipeThrough before trusting the native response stream", () => {
assert.equal(hasUsableNativeResponseStream({ body: null }, {}), false);
assert.equal(
hasUsableNativeResponseStream(
{ body: null },
{ pipeThrough() {} },
),
true,
);
assert.equal(
hasUsableNativeResponseStream({}, { pipeThrough() {} }),
false,
);
});
test("detects SSE, NDJSON, Range, and conventional stream endpoints", () => {
assert.equal(
isFirefox52StreamingRequest("/api/jobs/1", {
headers: { Accept: "text/event-stream" },
}),
true,
);
assert.equal(
isFirefox52StreamingRequest("/api/skill-addresses/scan", {
headers: { Accept: "application/x-ndjson" },
}),
true,
);
assert.equal(
isFirefox52StreamingRequest("/files/report.md", {
headers: { Range: "bytes=0-4095" },
}),
true,
);
assert.equal(isFirefox52StreamingRequest("/api/runs/123/stream"), true);
assert.equal(isFirefox52StreamingRequest("/api/sessions/123/events"), true);
});
test("detects legacy streaming contracts before issuing a POST", () => {
assert.equal(isFirefox52StreamingRequest("/api/multi-agent/init"), true);
assert.equal(isFirefox52StreamingRequest("/api/writing/rewrite"), true);
assert.equal(
isFirefox52StreamingRequest("/chat/chat", {
body: JSON.stringify({ query: "hello", stream: true }),
}),
true,
);
assert.equal(
isFirefox52StreamingRequest("/api/tasks", {
method: "POST",
body: JSON.stringify({ title: "stream" }),
}),
false,
);
});
test("does not route ordinary requests away from native fetch", () => {
assert.equal(isFirefox52StreamingRequest("/api/threads"), false);
assert.equal(
isFirefox52StreamingRequest("/api/files/export", {
headers: { Accept: "application/pdf" },
}),
false,
);
});
test("parses repeated and folded XHR response headers", () => {
const headers = parseXhrResponseHeaders(
"Content-Type: text/event-stream\r\nX-Trace: first\r\nX-Trace: second\r\n X-tail\r\n",
);
assert.equal(headers.get("content-type"), "text/event-stream");
assert.equal(headers.get("x-trace"), "first, second X-tail");
});
@@ -0,0 +1,335 @@
import { ReadableStream as Firefox52ReadableStream } from "web-streams-polyfill/es5";
type StreamingRequestInfo = RequestInfo | URL;
const STREAM_PATH_PATTERN = /(?:\/stream(?:[/?#]|$)|\/events(?:[/?#]|$))/i;
const LEGACY_STREAM_PATHS = [
"/api/multi-agent/init",
"/api/writing/rewrite",
];
function requestUrl(input: StreamingRequestInfo): string {
if (typeof input === "string") return input;
if (typeof URL !== "undefined" && input instanceof URL) return input.href;
return (input as Request).url ?? "";
}
function requestHeaders(input: StreamingRequestInfo, init?: RequestInit): Headers {
const headers = new Headers(
typeof Request !== "undefined" && input instanceof Request
? input.headers
: undefined,
);
if (init?.headers) {
new Headers(init.headers).forEach((value, name) => headers.set(name, value));
}
return headers;
}
function bodyRequestsStreaming(body: BodyInit | null | undefined): boolean {
if (typeof body !== "string") return false;
// The older RAG endpoints expose streaming behind a JSON flag instead of a
// dedicated URL. Avoid parsing arbitrary large request bodies unless the
// marker is present.
if (!/["']stream["']\s*:/.test(body)) return false;
try {
const parsed = JSON.parse(body) as { stream?: unknown };
return parsed.stream === true;
} catch {
return /["']stream["']\s*:\s*true\b/i.test(body);
}
}
/**
* Identify requests whose callers consume `Response.body`. Firefox 52 ships
* fetch but exposes no readable response stream, so these requests must use
* the incremental XHR bridge before they are sent.
*/
export function isFirefox52StreamingRequest(
input: StreamingRequestInfo,
init?: RequestInit,
): boolean {
const url = requestUrl(input);
const headers = requestHeaders(input, init);
const accept = headers.get("Accept")?.toLowerCase() ?? "";
if (
accept.includes("text/event-stream") ||
accept.includes("application/x-ndjson")
) {
return true;
}
if (headers.has("Range")) return true;
if (STREAM_PATH_PATTERN.test(url)) return true;
if (LEGACY_STREAM_PATHS.some((path) => url.includes(path))) return true;
return bodyRequestsStreaming(init?.body);
}
export function parseXhrResponseHeaders(rawHeaders: string): Headers {
const headers = new Headers();
let lastName = "";
for (const line of rawHeaders.replace(/\r\n/g, "\n").split("\n")) {
if (!line) continue;
if (/^[\t ]/.test(line) && lastName) {
headers.set(lastName, `${headers.get(lastName) ?? ""} ${line.trim()}`);
continue;
}
const separator = line.indexOf(":");
if (separator <= 0) continue;
lastName = line.slice(0, separator).trim();
headers.append(lastName, line.slice(separator + 1).trim());
}
return headers;
}
function abortError(): Error {
try {
return new DOMException("The operation was aborted.", "AbortError");
} catch {
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
}
function networkError(): TypeError {
return new TypeError("Network request failed");
}
export function hasUsableNativeResponseStream(
responsePrototype: object | null | undefined,
readableStreamPrototype: object | null | undefined,
): boolean {
return (
responsePrototype != null &&
"body" in responsePrototype &&
readableStreamPrototype != null &&
typeof Reflect.get(readableStreamPrototype, "pipeThrough") === "function"
);
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
let length = 0;
for (const chunk of chunks) length += chunk.byteLength;
const result = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function createStreamingResponse(
xhr: XMLHttpRequest,
body: ReadableStream<Uint8Array>,
): Response {
let used = false;
const consume = async (): Promise<Uint8Array> => {
if (used) throw new TypeError("Body has already been consumed.");
used = true;
const reader = body.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const result = await reader.read();
if (result.done) return concatBytes(chunks);
if (result.value) chunks.push(result.value);
}
};
const response = {
get body() {
used = true;
return body;
},
get bodyUsed() {
return used;
},
headers: parseXhrResponseHeaders(xhr.getAllResponseHeaders()),
ok: xhr.status >= 200 && xhr.status < 300,
redirected: false,
status: xhr.status,
statusText: xhr.statusText,
type: "basic" as ResponseType,
url: xhr.responseURL || "",
async arrayBuffer() {
const bytes = await consume();
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
},
async blob() {
const bytes = await consume();
const data = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
return new Blob([data], {
type: response.headers.get("Content-Type") ?? "",
});
},
async json() {
return JSON.parse(await response.text()) as unknown;
},
async text() {
return new TextDecoder().decode(await consume());
},
clone() {
throw new TypeError("A Firefox 52 streaming response cannot be cloned.");
},
async formData() {
throw new TypeError("Streaming form data is not supported in Firefox 52.");
},
bytes: consume,
};
return response as Response;
}
function requestMethod(input: StreamingRequestInfo, init?: RequestInit): string {
return (
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET")
).toUpperCase();
}
function xhrStreamingFetch(
input: StreamingRequestInfo,
init?: RequestInit,
): Promise<Response> {
return new Promise((resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted) {
reject(abortError());
return;
}
const xhr = new XMLHttpRequest();
const headers = requestHeaders(input, init);
const encoder = new TextEncoder();
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
let responseResolved = false;
let terminal = false;
let readIndex = 0;
let trailingHighSurrogate = "";
const body = new Firefox52ReadableStream<Uint8Array>({
start(nextController) {
controller = nextController as unknown as ReadableStreamDefaultController<Uint8Array>;
},
cancel() {
terminal = true;
xhr.abort();
},
}) as unknown as ReadableStream<Uint8Array>;
const settleResponse = () => {
if (responseResolved || xhr.readyState < XMLHttpRequest.HEADERS_RECEIVED) {
return;
}
responseResolved = true;
resolve(createStreamingResponse(xhr, body));
};
const pushAvailableText = (flush: boolean) => {
if (terminal || !controller) return;
let current = "";
try {
current = xhr.responseText || "";
} catch {
return;
}
let chunk = trailingHighSurrogate + current.slice(readIndex);
readIndex = current.length;
trailingHighSurrogate = "";
if (!flush && chunk.length > 0) {
const last = chunk.charCodeAt(chunk.length - 1);
if (last >= 0xd800 && last <= 0xdbff) {
trailingHighSurrogate = chunk.slice(-1);
chunk = chunk.slice(0, -1);
}
}
if (chunk) controller.enqueue(encoder.encode(chunk));
};
const fail = (error: Error) => {
if (terminal) return;
terminal = true;
if (responseResolved) controller?.error(error);
else reject(error);
};
xhr.onreadystatechange = settleResponse;
xhr.onprogress = () => {
settleResponse();
pushAvailableText(false);
};
xhr.onload = () => {
settleResponse();
if (terminal) return;
pushAvailableText(true);
terminal = true;
controller?.close();
};
xhr.onerror = () => fail(networkError());
xhr.ontimeout = () => fail(networkError());
xhr.onabort = () => {
if (!terminal) fail(abortError());
};
const onAbort = () => {
if (terminal) return;
xhr.abort();
fail(abortError());
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
xhr.open(requestMethod(input, init), requestUrl(input), true);
xhr.responseType = "text";
xhr.withCredentials = init?.credentials === "include";
headers.forEach((value, name) => xhr.setRequestHeader(name, value));
const requestBody = init?.body;
if (
requestBody != null &&
typeof ReadableStream !== "undefined" &&
requestBody instanceof ReadableStream
) {
throw new TypeError("Streaming request bodies are not supported in Firefox 52.");
}
xhr.send((requestBody ?? null) as XMLHttpRequestBodyInit | null);
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
});
}
let installed = false;
/** Install the XHR response-stream bridge while retaining native fetch for ordinary calls. */
export function installFirefox52StreamingFetch(): boolean {
if (installed || typeof window === "undefined" || typeof window.fetch !== "function") {
return false;
}
if (
hasUsableNativeResponseStream(
typeof Response === "undefined" ? undefined : Response.prototype,
typeof ReadableStream === "undefined" ? undefined : ReadableStream.prototype,
)
) {
return false;
}
const nativeFetch = window.fetch.bind(window);
window.fetch = ((input: StreamingRequestInfo, init?: RequestInit) => {
if (isFirefox52StreamingRequest(input, init)) {
return xhrStreamingFetch(input, init);
}
return nativeFetch(input, init);
}) as typeof window.fetch;
installed = true;
return true;
}
+444
View File
@@ -0,0 +1,444 @@
import "core-js/stable";
import "regenerator-runtime/runtime";
import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only";
import "intersection-observer";
import "pepjs";
import ResizeObserverPolyfill from "resize-observer-polyfill";
import {
ByteLengthQueuingStrategy,
CountQueuingStrategy,
ReadableStream,
TransformStream,
WritableStream,
} from "web-streams-polyfill/es5";
import { installFirefox52StreamingFetch } from "./firefox52-fetch";
function isFirefox52Browser(): boolean {
if (
typeof document !== "undefined" &&
document.documentElement.getAttribute("data-browser-compat") === "firefox52"
) {
return true;
}
if (typeof navigator === "undefined") return false;
const match = navigator.userAgent.match(/Firefox\/(\d+)/i);
return match != null && Number(match[1]) <= 52;
}
function readBlob(blob: Blob, mode: "arrayBuffer" | "text"): Promise<ArrayBuffer | string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Failed to read Blob"));
reader.onabort = () => reject(new DOMException("The operation was aborted.", "AbortError"));
reader.onload = () => resolve(reader.result as ArrayBuffer | string);
if (mode === "arrayBuffer") reader.readAsArrayBuffer(blob);
else reader.readAsText(blob);
});
}
function installBlobMethods() {
if (typeof Blob === "undefined") return;
if (typeof Blob.prototype.arrayBuffer !== "function") {
Object.defineProperty(Blob.prototype, "arrayBuffer", {
configurable: true,
writable: true,
value(this: Blob) {
return readBlob(this, "arrayBuffer") as Promise<ArrayBuffer>;
},
});
}
if (typeof Blob.prototype.text !== "function") {
Object.defineProperty(Blob.prototype, "text", {
configurable: true,
writable: true,
value(this: Blob) {
return readBlob(this, "text") as Promise<string>;
},
});
}
if (typeof Blob.prototype.stream !== "function") {
Object.defineProperty(Blob.prototype, "stream", {
configurable: true,
writable: true,
value(this: Blob) {
const blob = this;
return new ReadableStream<Uint8Array>({
async start(controller) {
try {
controller.enqueue(new Uint8Array(await blob.arrayBuffer()));
controller.close();
} catch (error) {
controller.error(error);
}
},
});
},
});
}
}
function installRandomUuid() {
if (typeof crypto === "undefined") return;
const legacyCrypto = crypto as Crypto & { randomUUID?: () => string };
if (typeof legacyCrypto.randomUUID === "function") return;
Object.defineProperty(legacyCrypto, "randomUUID", {
configurable: true,
value() {
const bytes = new Uint8Array(16);
legacyCrypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
},
});
}
/**
* Mermaid 11 creates detached stylesheets with `new CSSStyleSheet()`.
* Firefox 52 exposes CSSStyleSheet as a function, but invoking it as a
* constructor throws "Illegal constructor". A stylesheet obtained from a
* temporary <style> element remains usable after the element is detached, so
* it provides the same insertRule/cssRules surface Mermaid needs.
*/
function installConstructableStyleSheet() {
const nativeConstructor = Reflect.get(window, "CSSStyleSheet") as
| (new () => CSSStyleSheet)
| undefined;
if (typeof nativeConstructor === "function") {
try {
new nativeConstructor();
return;
} catch {
// Firefox 52 exposes CSSStyleSheet but does not allow construction.
}
}
const Firefox52CSSStyleSheet = function () {
const style = document.createElement("style");
(document.head ?? document.documentElement).appendChild(style);
const sheet = style.sheet;
style.parentNode?.removeChild(style);
if (!sheet) {
throw new Error("Unable to create a compatibility CSSStyleSheet");
}
return sheet;
} as unknown as new () => CSSStyleSheet;
if (nativeConstructor?.prototype) {
Firefox52CSSStyleSheet.prototype = nativeConstructor.prototype;
}
Object.defineProperty(window, "CSSStyleSheet", {
configurable: true,
writable: true,
value: Firefox52CSSStyleSheet,
});
}
function installClipboardWriteText() {
if (navigator.clipboard?.writeText) return;
const clipboard = {
writeText(text: string): Promise<void> {
return new Promise((resolve, reject) => {
if (!document.body) {
reject(new Error("Clipboard is not available"));
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
try {
if (!document.execCommand("copy")) throw new Error("Copy command failed");
resolve();
} catch (error) {
reject(error);
} finally {
textarea.remove();
}
});
},
};
try {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: clipboard,
});
} catch {
// A locked-down browser may reject extending Navigator. Call sites that
// use the shared clipboard helper still retain its execCommand fallback.
}
}
function installFormRequestSubmit() {
if (
typeof HTMLFormElement === "undefined" ||
typeof HTMLFormElement.prototype.requestSubmit === "function"
) {
return;
}
Object.defineProperty(HTMLFormElement.prototype, "requestSubmit", {
configurable: true,
writable: true,
value(this: HTMLFormElement, submitter?: HTMLElement) {
if (submitter) {
const button = submitter as HTMLButtonElement | HTMLInputElement;
const type = button.type?.toLowerCase();
if (
(button.tagName !== "BUTTON" && button.tagName !== "INPUT") ||
(type !== "submit" && type !== "image")
) {
throw new TypeError("The specified element is not a submit button.");
}
if (button.form !== this) {
throw new DOMException(
"The specified element is not owned by this form.",
"NotFoundError",
);
}
button.click();
return;
}
const temporarySubmit = document.createElement("input");
temporarySubmit.type = "submit";
temporarySubmit.hidden = true;
this.appendChild(temporarySubmit);
temporarySubmit.click();
temporarySubmit.remove();
},
});
}
function scrollElementIntoNearestView(element: Element) {
const elementRect = element.getBoundingClientRect();
let ancestor = element.parentElement;
while (ancestor) {
const style = window.getComputedStyle(ancestor);
const canScrollY =
ancestor.scrollHeight > ancestor.clientHeight &&
(style.overflowY === "auto" || style.overflowY === "scroll");
if (canScrollY) {
const ancestorRect = ancestor.getBoundingClientRect();
if (elementRect.top < ancestorRect.top) {
ancestor.scrollTop -= ancestorRect.top - elementRect.top;
} else if (elementRect.bottom > ancestorRect.bottom) {
ancestor.scrollTop += elementRect.bottom - ancestorRect.bottom;
}
}
ancestor = ancestor.parentElement;
}
const finalRect = element.getBoundingClientRect();
if (finalRect.top < 0) {
window.scrollBy(0, finalRect.top);
} else if (finalRect.bottom > window.innerHeight) {
window.scrollBy(0, finalRect.bottom - window.innerHeight);
}
}
function scrollElementIntoCenteredView(element: Element) {
let ancestor = element.parentElement;
while (ancestor) {
const style = window.getComputedStyle(ancestor);
if (
ancestor.scrollHeight > ancestor.clientHeight &&
(style.overflowY === "auto" || style.overflowY === "scroll")
) {
const elementRect = element.getBoundingClientRect();
const ancestorRect = ancestor.getBoundingClientRect();
ancestor.scrollTop +=
elementRect.top + elementRect.height / 2 -
(ancestorRect.top + ancestorRect.height / 2);
return;
}
ancestor = ancestor.parentElement;
}
const rect = element.getBoundingClientRect();
window.scrollBy(0, rect.top + rect.height / 2 - window.innerHeight / 2);
}
function installScrollIntoViewOptions() {
if (typeof Element === "undefined") return;
const nativeScrollIntoView = Element.prototype.scrollIntoView;
if (typeof nativeScrollIntoView !== "function") return;
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value(this: Element, options?: boolean | ScrollIntoViewOptions) {
if (typeof options !== "object" || options === null) {
nativeScrollIntoView.call(this, options);
return;
}
if (options.block === "nearest") {
scrollElementIntoNearestView(this);
return;
}
if (options.block === "center") {
scrollElementIntoCenteredView(this);
return;
}
nativeScrollIntoView.call(this, options);
},
});
}
class Firefox52TextDecoderStream {
readonly readable: ReadableStream<string>;
readonly writable: WritableStream<BufferSource>;
readonly encoding: string;
readonly fatal: boolean;
readonly ignoreBOM: boolean;
constructor(label = "utf-8", options: TextDecoderOptions = {}) {
const decoder = new TextDecoder(label, options);
const transform = new TransformStream<BufferSource, string>({
transform(chunk, controller) {
const text = decoder.decode(chunk, { stream: true });
if (text) controller.enqueue(text);
},
flush(controller) {
const text = decoder.decode();
if (text) controller.enqueue(text);
},
});
this.readable = transform.readable;
this.writable = transform.writable;
this.encoding = decoder.encoding;
this.fatal = decoder.fatal;
this.ignoreBOM = decoder.ignoreBOM;
}
}
class Firefox52ResizeObserver {
private readonly delegate: ResizeObserver;
constructor(callback: ResizeObserverCallback) {
this.delegate = new ResizeObserverPolyfill((entries) => {
const normalizedEntries = entries.map((entry) => {
const size = {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height,
};
return {
target: entry.target,
contentRect: entry.contentRect,
borderBoxSize: [size],
contentBoxSize: [size],
devicePixelContentBoxSize: [size],
} as ResizeObserverEntry;
});
callback(normalizedEntries, this as unknown as ResizeObserver);
});
}
observe(target: Element) {
this.delegate.observe(target);
}
unobserve(target: Element) {
this.delegate.unobserve(target);
}
disconnect() {
this.delegate.disconnect();
}
}
/**
* Browser APIs used by components during their first render/effect pass but
* missing from Firefox 52. Language built-ins are supplied by Vite's legacy
* bundle; these DOM observer shims need to be installed explicitly.
*/
export function installFirefox52DomPolyfills() {
if (typeof window === "undefined") return;
if (!isFirefox52Browser()) return;
document.documentElement.setAttribute("data-browser-compat", "firefox52");
const streamPolyfills = {
ReadableStream,
WritableStream,
TransformStream,
ByteLengthQueuingStrategy,
CountQueuingStrategy,
};
for (const [name, implementation] of Object.entries(streamPolyfills)) {
if (typeof Reflect.get(window, name) === "undefined") {
Object.defineProperty(window, name, {
configurable: true,
writable: true,
value: implementation,
});
}
}
installBlobMethods();
installRandomUuid();
installConstructableStyleSheet();
installClipboardWriteText();
installFormRequestSubmit();
installScrollIntoViewOptions();
if (typeof window.TextDecoderStream === "undefined") {
Object.defineProperty(window, "TextDecoderStream", {
configurable: true,
writable: true,
value: Firefox52TextDecoderStream,
});
}
if (typeof window.matchMedia === "function") {
const mediaQueryPrototype = Object.getPrototypeOf(
window.matchMedia("(min-width: 0px)"),
) as MediaQueryList;
if (
typeof mediaQueryPrototype.addEventListener !== "function" &&
typeof mediaQueryPrototype.addListener === "function"
) {
Object.defineProperty(mediaQueryPrototype, "addEventListener", {
configurable: true,
writable: true,
value(this: MediaQueryList, _type: string, listener: EventListener) {
this.addListener(listener as unknown as (event: MediaQueryListEvent) => void);
},
});
Object.defineProperty(mediaQueryPrototype, "removeEventListener", {
configurable: true,
writable: true,
value(this: MediaQueryList, _type: string, listener: EventListener) {
this.removeListener(listener as unknown as (event: MediaQueryListEvent) => void);
},
});
}
}
if (typeof window.ResizeObserver === "undefined") {
Object.defineProperty(window, "ResizeObserver", {
configurable: true,
writable: true,
value: Firefox52ResizeObserver,
});
}
installFirefox52StreamingFetch();
}
installFirefox52DomPolyfills();
@@ -240,14 +240,14 @@ export function resolveWikiIndexPageLinks(
}
return markdown.replace(
/(?<!!)\[([^\]\n]+)\]\(([^)\n]+)\)/g,
(original, rawLabel: string, rawUrl: string) => {
/(^|[^!])\[([^\]\n]+)\]\(([^)\n]+)\)/g,
(original, prefix: string, rawLabel: string, rawUrl: string) => {
if (!isLegacyWikiPlaceholderUrl(rawUrl)) return original;
const normalizedLabel = normalizeWikiLinkLabel(rawLabel);
const withoutSummarySuffix = normalizedLabel.replace(/\s+-\s+summary$/i, "");
const target = pagesByLabel.get(normalizedLabel) || pagesByLabel.get(withoutSummarySuffix);
if (!target) return original;
return `[${rawLabel}](${buildPageUrl(knowledgeBaseId, target.slug)})`;
return `${prefix}[${rawLabel}](${buildPageUrl(knowledgeBaseId, target.slug)})`;
},
);
}
@@ -272,7 +272,7 @@ export function renderableWikiMarkdown(
["index", "home", "readme"].includes(slugName || "")
);
});
const existingMarkdownLinkCount = (baseMarkdown.match(/(?<!!)\[[^\]\n]+\]\([^)\n]+\)/g) || []).length;
const existingMarkdownLinkCount = (baseMarkdown.match(/(^|[^!])\[[^\]\n]+\]\([^)\n]+\)/g) || []).length;
const catalogue = isIndex && existingMarkdownLinkCount === 0
? buildWikiIndexCatalogMarkdown(pages, knowledgeBaseId, buildPageUrl, indexGroups)
: "";
@@ -0,0 +1,273 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "../llmwiki/source-links";
export type RetrievedFile = {
key: string;
name: string;
source?: string;
} & (
| { kind: "artifact"; path: string }
| { kind: "knowledge"; target: WeKnoraSourceTarget; quote?: string }
| { kind: "external"; url: string; previewUrl?: string }
);
const MAX_FILES = 100;
const MAX_NODES = 2_000;
const MAX_PAYLOAD_CHARS = 1_000_000;
const CHILD_KEYS = [
"result", "results", "response", "payload", "output", "data", "items",
"hits", "documents", "records", "matches", "sources", "files", "attachments",
"file", "document", "attachment", "structuredContent", "stdout", "json",
];
const FILE_LIST_KEYS = new Set(["files", "file", "attachments", "attachment", "hits", "documents", "records", "matches", "sources", "results"]);
const NAME_KEYS = [
"document_file_name", "knowledge_filename", "file_name", "fileName", "filename",
"original_name", "originalName", "document_title", "title", "name",
];
const PATH_KEYS = ["file_path", "filePath", "path", "artifact_path", "artifactPath"];
const DOWNLOAD_KEYS = ["download_url", "downloadUrl", "file_url", "fileUrl", "attachment_url", "attachmentUrl"];
const PREVIEW_KEYS = ["preview_url", "previewUrl"];
const FILE_EXTENSION = /\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|md|markdown|csv|tsv|json|xml|ya?ml|png|jpe?g|gif|webp|svg|bmp|zip|rar|7z|tar|gz|mp3|mp4|wav)$/i;
const IGNORED_TOOLS = new Set(["present_files", "write_file", "str_replace", "read_file", "read_multiple_files", "glob", "grep", "ls", "page_report_template", "skill_view", "skill_list", "search_skills"]);
function isSkillInvocation(name: string, args: Record<string, unknown>, skillNames: ReadonlySet<string>): boolean {
if (IGNORED_TOOLS.has(name)) return false;
if (skillNames.has(name)) return true;
if (!/^(?:bash|shell|python|execute_code|terminal)$/.test(name)) return false;
// Require an actual skill script path, not merely a prior SKILL.md read.
return Object.values(args).some((value) => typeof value === "string" &&
/(?:^|[\s"'\/])(?:mnt\/)?skills\/(?:public|custom)\/[^\s/]+\/(?:scripts\/)?[^\s"']+\.(?:py|sh)(?=[\s"']|$)/.test(value));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function text(row: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
if (typeof row[key] === "string" && row[key].trim()) return row[key].trim();
}
return undefined;
}
function filename(value: string): string {
return value.replace(/\\/g, "/").split("/").at(-1)?.replace(/[\u0000-\u001f]/g, "").slice(0, 255) || "检索文件";
}
/** Only the current thread's served folders; never a host path or skill source. */
export function retrievedArtifactPath(value: string): string | null {
const path = value.trim().replace(/\\/g, "/");
if (!/^\/mnt\/user-data\/workspace\/.+/.test(path) || path.endsWith("/")) return null;
if (/[\u0000-\u001f?#]/.test(path)) return null;
for (const segment of path.split("/")) {
let decoded = segment;
// Reject encoded traversal/separators as well as literal dot segments.
for (let attempt = 0; attempt < 3; attempt++) {
if (decoded === "." || decoded === ".." || /[/\\\u0000-\u001f]/.test(decoded)) return null;
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch {
break;
}
}
if (decoded === "." || decoded === ".." || /[/\\\u0000-\u001f]/.test(decoded)) return null;
}
return path;
}
function httpUrl(value?: string): string | undefined {
if (!value || /[\u0000-\u001f]/.test(value)) return undefined;
try {
const url = new URL(value);
if (!/^(?:https?:)$/.test(url.protocol) || url.username || url.password) return undefined;
return url.href;
} catch {
return undefined;
}
}
function nameFromUrl(url: string): string {
const parsed = new URL(url);
const supplied = parsed.searchParams.get("filename") || parsed.searchParams.get("fileName");
if (supplied) return filename(supplied);
try {
return filename(decodeURIComponent(parsed.pathname));
} catch {
return filename(parsed.pathname);
}
}
function failed(row: Record<string, unknown>): boolean {
return row.success === false || row.ok === false || row.status === "error" || Boolean(row.error)
|| row.__displaySample === true || row.displaySample === true;
}
/** A bounded parse of pretty-printed JSON following a skill's console logs. */
function loggedJson(raw: string): unknown[] {
if (raw.length > 64_000) return [];
const results: unknown[] = [];
const starts = /(?:^|\n)\s*(?:```(?:json)?\s*)?([\[{])/g;
let match: RegExpExecArray | null;
let attempts = 0;
while ((match = starts.exec(raw)) && attempts++ < 16) {
const start = match.index + match[0].lastIndexOf(match[1]);
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = start; index < raw.length; index++) {
const char = raw[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
continue;
}
if (char === '"') quoted = true;
else if (char === "{" || char === "[") depth++;
else if (char === "}" || char === "]") {
if (--depth === 0) {
try {
results.push(JSON.parse(raw.slice(start, index + 1)));
starts.lastIndex = index + 1;
} catch { /* Try the next bounded candidate. */ }
break;
}
}
}
}
return results;
}
function fileFromRow(row: Record<string, unknown>, fileContext: boolean): RetrievedFile | null {
const meta = isRecord(row.metadata) ? row.metadata : isRecord(row.meta) ? row.meta : {};
const fields = { ...meta, ...row };
if (failed(fields) || fields.is_directory === true || fields.isDirectory === true || fields.type === "directory") return null;
const name = text(fields, NAME_KEYS);
const source = text(fields, ["knowledge_base_name", "source_name", "source"]);
for (const key of PATH_KEYS) {
const path = typeof fields[key] === "string" ? retrievedArtifactPath(fields[key]) : null;
if (path && fileContext) return { kind: "artifact", key: `artifact:${path}`, path, name: filename(name || path), source };
}
const target = resolveWeKnoraSourceTarget({
url: text(fields, ["url"]),
knowledge_base_id: text(fields, ["knowledge_base_id", "knowledgeBaseId", "mapping_id", "mappingId"]),
document_id: text(fields, ["document_id", "documentId", "knowledge_id", "knowledgeId"]),
chunk_id: text(fields, ["chunk_id", "chunkId"]),
wiki_slug: text(fields, ["wiki_slug", "wikiSlug"]),
});
if (target && !target.wikiSlug && fields.source_kind !== "wiki" && fields.type !== "llmwiki_wiki") {
return {
kind: "knowledge",
key: `knowledge:${target.knowledgeBaseId}:${target.documentId || target.chunkId}`,
name: filename(name || "知识库文档"), source, target,
quote: text(fields, ["content", "snippet", "content_preview"])?.slice(0, 6_000),
};
}
const explicitUrl = httpUrl(text(fields, DOWNLOAD_KEYS));
const generalUrl = httpUrl(text(fields, ["url", "href", "link"]));
const url = explicitUrl || (generalUrl && (fileContext || FILE_EXTENSION.test(name || "") || FILE_EXTENSION.test(new URL(generalUrl).pathname)) ? generalUrl : undefined);
if (!url) return null;
return {
kind: "external", key: `external:${url}`, url,
name: filename(name || nameFromUrl(url)), source,
previewUrl: httpUrl(text(fields, PREVIEW_KEYS)),
};
}
function contentText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.flatMap((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n");
}
/** Consume completed skill retrieval results, never generic tools or model prose. */
export function collectRetrievedFiles(messages: Message[], skillNames: ReadonlySet<string> = new Set()): RetrievedFile[] {
const calls = new Map<string, { name: string; args: Record<string, unknown> }>();
const generatedPaths = new Set<string>();
for (const message of messages) {
if (message.type !== "ai") continue;
for (const call of message.tool_calls ?? []) {
if (call.id) calls.set(call.id, { name: call.name, args: isRecord(call.args) ? call.args : {} });
if ((call.name === "write_file" || call.name === "str_replace") && typeof call.args?.path === "string") {
generatedPaths.add(call.args.path.replace(/\\/g, "/"));
}
}
}
const files = new Map<string, RetrievedFile>();
let visited = 0;
const append = (file: RetrievedFile | null) => {
if (file?.kind === "artifact" && generatedPaths.has(file.path)) return;
if (file?.kind === "external" && /\/mnt\/user-data\/(?:outputs|uploads)\//.test(new URL(file.url).pathname)) return;
if (file && files.size < MAX_FILES && !files.has(file.key)) files.set(file.key, file);
};
const appendPath = (value: string) => {
const path = retrievedArtifactPath(value);
if (path) append({ kind: "artifact", key: `artifact:${path}`, path, name: filename(path) });
};
const visit = (value: unknown, depth: number, fileContext = false) => {
if (depth > 8 || visited++ >= MAX_NODES || files.size >= MAX_FILES) return;
if (typeof value === "string") {
const raw = value.trim();
if (!raw || raw.length > MAX_PAYLOAD_CHARS || /^(?:Error:|Search failed:)/i.test(raw)) return;
if (/^[\[{]/.test(raw)) {
try {
visit(JSON.parse(raw), depth + 1, fileContext);
return;
} catch { /* A skill may print logs before its JSON. Parse lines below. */ }
}
const logged = loggedJson(raw);
if (logged.length) {
for (const parsed of logged) visit(parsed, depth + 1, fileContext);
return;
}
if (fileContext && retrievedArtifactPath(raw) && !raw.includes("\n")) {
appendPath(raw);
return;
}
const url = httpUrl(raw);
if (url && (fileContext || FILE_EXTENSION.test(new URL(url).pathname))) {
append({ kind: "external", key: `external:${url}`, url, name: nameFromUrl(url) });
return;
}
for (const line of raw.split(/\r?\n/).slice(0, 1_000)) {
const pathLine = line.trim();
if (/^[\[{]/.test(pathLine)) {
try { visit(JSON.parse(pathLine), depth + 1, fileContext); } catch { /* Ordinary log line. */ }
}
}
return;
}
if (Array.isArray(value)) {
for (const item of value.slice(0, MAX_NODES)) visit(item, depth + 1, fileContext);
return;
}
if (!isRecord(value) || failed(value)) return;
append(fileFromRow(value, fileContext));
if (value.type === "text" && typeof value.text === "string") visit(value.text, depth + 1, fileContext);
for (const key of CHILD_KEYS) {
if (value[key] !== undefined) visit(value[key], depth + 1, fileContext || FILE_LIST_KEYS.has(key));
}
};
for (const message of messages) {
if (message.type !== "tool" || message.status === "error") continue;
const call = message.tool_call_id ? calls.get(message.tool_call_id) : undefined;
const name = call?.name || message.name || "";
if (!call || !isSkillInvocation(name, call.args, skillNames)) continue;
const raw = contentText(message.content);
if (/^(?:Error:|Search failed:)/i.test(raw.trim())) continue;
visit(message.content, 0);
visit(message.artifact, 0);
}
return [...files.values()];
}
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import { buildQuestionItems } from "../../components/workspace/question-navigator-data";
import { groupMessages, isHiddenFromUIMessage } from "./utils";
const user: Message = { type: "human", id: "question", content: "画一张态势图" };
const answer: Message = { type: "ai", id: "answer", content: "地图已生成" };
test("new and historical Todo reminders never create chat groups or navigator questions", () => {
const reminders: Message[] = [
{ type: "human", id: "legacy-recall", name: "todo_reminder", content: "Internal recall" },
{ type: "human", id: "legacy-completion", name: "todo_completion_reminder", content: "Internal completion" },
{ type: "human", id: "new-completion", name: "todo_completion_reminder", content: "Internal completion", additional_kwargs: { hide_from_ui: true, lc_source: "todo" } },
];
for (const reminder of reminders) assert.equal(isHiddenFromUIMessage(reminder), true);
const messages = [user, answer, ...reminders, { ...answer, id: "followup" }];
assert.deepEqual(groupMessages(messages, (group) => group.type), ["human", "assistant", "assistant"]);
assert.deepEqual(buildQuestionItems(messages), [{ anchorId: "message-question", summary: "画一张态势图" }]);
});
test("users quoting reminder tags or similar names remain visible and navigable", () => {
const quoted: Message = { type: "human", id: "quote", content: "解释 <system_reminder>todo_reminder</system_reminder> 的意思" };
const similar: Message = { type: "human", id: "similar", name: "todo_completion_reminder_example", content: "这是用户的问题" };
for (const message of [quoted, similar]) assert.equal(isHiddenFromUIMessage(message), false);
assert.deepEqual(groupMessages([quoted, similar], (group) => group.type), ["human", "human"]);
assert.deepEqual(buildQuestionItems([quoted, similar]).map((question) => question.anchorId), ["message-quote", "message-similar"]);
assert.match(buildQuestionItems([quoted])[0]!.summary, /<system_reminder>/);
});
test("navigator preserves raw fallback indexes when hidden context precedes a real question", () => {
assert.deepEqual(buildQuestionItems([
{ type: "human", content: "Model context", additional_kwargs: { hide_from_ui: true } },
{ type: "human", content: "# 第二个问题" },
]), [{ anchorId: "message-1", summary: "第二个问题" }]);
});
@@ -3,7 +3,14 @@ import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import { groupMessages, isHiddenFromUIMessage, isViewedImagesContextMessage, stripUploadedFilesTag } from "./utils";
import {
groupMessages,
hasOnlyInternalToolCalls,
hasVisibleToolCalls,
isHiddenFromUIMessage,
isViewedImagesContextMessage,
stripUploadedFilesTag,
} from "./utils";
function groupTypes(messages: Message[]) {
const types: string[] = [];
@@ -213,6 +220,173 @@ test("tool messages still attach to an open processing group", () => {
assert.deepEqual(types, ["assistant:processing"]);
});
test("begin_write_file handshake is hidden while the real write_file remains", () => {
const groups = groupMessages(
[
{ type: "human", id: "h1", content: "写一份报告" } as Message,
{
type: "ai",
id: "a-begin",
content: "",
tool_calls: [
{
id: "begin-1",
name: "begin_write_file",
args: {
description: "写入报告",
path: "/mnt/user-data/outputs/report.md",
},
},
],
} as Message,
{
type: "tool",
id: "t-begin",
name: "begin_write_file",
content: '{"status":"ready"}',
tool_call_id: "begin-1",
} as Message,
{
type: "ai",
id: "a-write",
content: "",
additional_kwargs: { reasoning_content: "正文生成阶段的思考" },
tool_calls: [
{
id: "write-1",
name: "write_file",
args: { path: "/mnt/user-data/outputs/report.md", content: "# 报告" },
},
],
} as Message,
{
type: "tool",
id: "t-write",
name: "write_file",
content: "OK",
tool_call_id: "write-1",
} as Message,
],
(group) => group,
);
assert.deepEqual(groups.map((group) => group.type), ["human", "assistant:processing"]);
assert.deepEqual(
groups[1]?.messages.map((message) =>
message.type === "ai" ? message.tool_calls?.[0]?.name : message.name,
),
["write_file", "write_file"],
);
assert.equal(
groups[1]?.messages[0]?.additional_kwargs?.reasoning_content,
"正文生成阶段的思考",
);
});
test("begin_write_file-only preface and reasoning never create chat groups", () => {
const groups = groupMessages(
[
{ type: "human", id: "h1", content: "写一份报告" } as Message,
{
type: "ai",
id: "a-begin",
content: "好的,我来写这份报告。",
additional_kwargs: { reasoning_content: "先准备写文件" },
tool_calls: [
{
id: "begin-1",
name: "begin_write_file",
args: { path: "/mnt/user-data/outputs/report.md" },
},
],
} as Message,
{
type: "tool",
id: "t-begin",
name: "begin_write_file",
content: '{"status":"ready"}',
tool_call_id: "begin-1",
} as Message,
{
type: "ai",
id: "a-write",
content: "",
tool_calls: [
{
id: "write-1",
name: "write_file",
args: { path: "/mnt/user-data/outputs/report.md", content: "# 报告" },
},
],
} as Message,
],
(group) => group,
);
assert.deepEqual(groups.map((group) => group.type), ["human", "assistant:processing"]);
assert.deepEqual(groups[1]?.messages.map((message) => message.id), ["a-write"]);
});
test("internal write handshake does not count as visible placeholder output", () => {
const internal = {
type: "ai",
content: "好的,我来生成文件。",
additional_kwargs: { reasoning_content: "先规划文件内容。" },
tool_calls: [{ id: "begin-1", name: "begin_write_file", args: {} }],
} as Message;
const canonical = {
type: "ai",
content: "",
tool_calls: [{ id: "write-1", name: "write_file", args: {} }],
} as Message;
assert.equal(hasOnlyInternalToolCalls(internal), true);
assert.equal(hasOnlyInternalToolCalls(canonical), false);
assert.equal(hasVisibleToolCalls(internal), false);
assert.equal(hasVisibleToolCalls(canonical), true);
});
test("internal tool-call ids are released after their paired receipt", () => {
const groups = groupMessages(
[
{ type: "human", id: "h1", content: "写完后读取状态" } as Message,
{
type: "ai",
id: "a-begin",
content: "",
tool_calls: [
{ id: "reused-1", name: "begin_write_file", args: { path: "/tmp/a.md" } },
],
} as Message,
{
type: "tool",
id: "t-begin",
content: '{"status":"ready"}',
tool_call_id: "reused-1",
} as Message,
{
type: "ai",
id: "a-read",
content: "",
tool_calls: [
{ id: "reused-1", name: "read_file", args: { path: "/tmp/a.md" } },
],
} as Message,
{
type: "tool",
id: "t-read",
name: "read_file",
content: "visible result",
tool_call_id: "reused-1",
} as Message,
],
(group) => group,
);
assert.deepEqual(groups.map((group) => group.type), ["human", "assistant:processing"]);
assert.deepEqual(groups[1]?.messages.map((message) => message.id), ["a-read", "t-read"]);
});
test("stripUploadedFilesTag removes a public image URL block", () => {
const content = [
"<uploaded_files>",
@@ -243,7 +417,8 @@ test("isViewedImagesContextMessage hides legacy view_image injection bubbles", (
assert.equal(isViewedImagesContextMessage(legacy), true);
assert.equal(isHiddenFromUIMessage(legacy), true);
const types = groupMessages([legacy, { type: "ai", id: "a1", content: "ok" } as Message]).map(
const types = groupMessages(
[legacy, { type: "ai", id: "a1", content: "ok" } as Message],
(group) => group.type,
);
assert.deepEqual(types, ["assistant"]);
+52 -4
View File
@@ -1,6 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
import { isInternalToolCallName } from "@/core/tools/visibility";
interface GenericMessageGroup<T = string> {
type: T;
@@ -55,6 +56,7 @@ export function groupMessages<T>(
}
const groups: MessageGroup[] = [];
const internalToolCallIds = new Set<string>();
// Returns the last group if it can still accept tool messages
// (i.e. it's an in-flight processing group, not a terminal human/assistant group).
@@ -80,8 +82,24 @@ export function groupMessages<T>(
if (isHiddenFromUIMessage(message)) {
continue;
}
if (message.name === "todo_reminder") {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (isInternalToolCallName(toolCall.name) && toolCall.id) {
internalToolCallIds.add(toolCall.id);
}
}
}
if (hasOnlyInternalToolCalls(message)) {
// The whole handshake turn is transport state. Hiding just its tool call
// would leak any provider preface/reasoning as a standalone chat bubble.
continue;
}
if (
message.type === "tool" &&
(isInternalToolCallName(message.name) ||
internalToolCallIds.has(message.tool_call_id ?? ""))
) {
internalToolCallIds.delete(message.tool_call_id ?? "");
continue;
}
@@ -210,7 +228,7 @@ export function groupMessages<T>(
type: "assistant:subagent",
messages: [message],
});
} else if (hasReasoning(message) || hasToolCalls(message)) {
} else if (hasReasoning(message) || hasVisibleToolCalls(message)) {
const lastGroup = groups[groups.length - 1];
// Accumulate consecutive intermediate AI messages into one processing group.
if (lastGroup?.type !== "assistant:processing") {
@@ -226,7 +244,7 @@ export function groupMessages<T>(
// Not an else-if: a message with reasoning + content (but no tool calls) goes
// into the processing group above AND gets its own assistant bubble here.
if (hasContent(message) && !hasToolCalls(message)) {
if (hasContent(message) && !hasVisibleToolCalls(message)) {
groups.push({ id: message.id, type: "assistant", messages: [message] });
}
}
@@ -456,6 +474,27 @@ export function hasToolCalls(message: Message) {
);
}
export function hasVisibleToolCalls(message: Message) {
return (
message.type === "ai" &&
Boolean(
message.tool_calls?.some(
(toolCall) => !isInternalToolCallName(toolCall.name),
),
)
);
}
export function hasOnlyInternalToolCalls(message: Message) {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.length) &&
message.tool_calls!.every((toolCall) =>
isInternalToolCallName(toolCall.name),
)
);
}
export function hasPresentFiles(message: Message) {
return (
message.type === "ai" &&
@@ -633,6 +672,15 @@ export function isHiddenFromUIMessage(message: Message) {
if (message.additional_kwargs?.hide_from_ui === true) {
return true;
}
// Older checkpoints persisted TodoMiddleware's model-only HumanMessages
// before they carried hide_from_ui. Match their exact internal names; a
// real user may quote <system_reminder> in a question and must stay visible.
if (
message.type === "human" &&
(message.name === "todo_reminder" || message.name === "todo_completion_reminder")
) {
return true;
}
// ViewImageMiddleware injects vision context for the model only.
if (isViewedImagesContextMessage(message)) {
return true;
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping runner requires the explicit .ts suffix.
import { splitTextIntoAnimationSegments } from "./index.ts";
test("falls back without Intl.Segmenter while preserving the complete text", () => {
const input = "Artificial Intelligence, AI for Science";
const segments = splitTextIntoAnimationSegments(input, null);
assert.deepEqual(segments, [
"Artificial",
" ",
"Intelligence,",
" ",
"AI",
" ",
"for",
" ",
"Science",
]);
assert.equal(segments.join(""), input);
});
test("uses an available segmenter and preserves its segment order", () => {
class FakeSegmenter {
segment() {
return [{ segment: "alpha" }, { segment: "-" }, { segment: "beta" }];
}
}
const segments = splitTextIntoAnimationSegments(
"alpha-beta",
FakeSegmenter,
);
assert.deepEqual(segments, ["alpha", "-", "beta"]);
});
+40 -10
View File
@@ -52,10 +52,44 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
};
}
const CJK_TEXT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
export function rehypeSplitWordsIntoSpans() {
const CJK_TEXT_RE = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/;
type WordSegmenterConstructor = new (
locales?: string | string[],
options?: { granularity?: "grapheme" | "word" | "sentence" },
) => {
segment(input: string): Iterable<{ segment: string }>;
};
function getWordSegmenterConstructor(): WordSegmenterConstructor | null {
if (typeof Intl === "undefined") return null;
const candidate = Reflect.get(Intl, "Segmenter");
return typeof candidate === "function"
? (candidate as WordSegmenterConstructor)
: null;
}
export function splitTextIntoAnimationSegments(
text: string,
SegmenterConstructor: WordSegmenterConstructor | null =
getWordSegmenterConstructor(),
): string[] {
if (SegmenterConstructor) {
try {
const segmenter = new SegmenterConstructor("zh", {
granularity: "word",
});
return Array.from(segmenter.segment(text))
.map((segment) => segment.segment)
.filter(Boolean);
} catch {
// Some legacy Intl implementations expose a non-constructible stub.
}
}
return text.match(/\s+|[^\s]+/g) ?? [];
}
export function rehypeSplitWordsIntoSpans() {
return (tree: Root) => {
visit(tree, "element", ((node: Element) => {
if (
@@ -71,17 +105,13 @@ export function rehypeSplitWordsIntoSpans() {
newChildren.push(child);
return;
}
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const segments = segmenter.segment(child.value);
const words = Array.from(segments)
.map((segment) => segment.segment)
.filter(Boolean);
const words = splitTextIntoAnimationSegments(child.value);
words.forEach((word: string) => {
newChildren.push({
type: "element",
tagName: "span",
properties: {
className: "animate-fade-in",
className: ["animate-fade-in"],
},
children: [{ type: "text", value: word }],
});
Loaded 100 of 605 files, more files were not shown because too many files have changed in this diff. Show more