Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1ad5a3e5 |
No files matched your search
+3
-10
@@ -22,9 +22,7 @@ frontend-web/node_modules/
|
||||
frontend-web/.pnpm-store/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
frontend-web/vite-dev.log
|
||||
frontend-web/vite-dev.err.log
|
||||
frontend-web/.rspack-cache/
|
||||
frontend-web/dist-firefox52/
|
||||
frontend-web/vite-dev.err.log
|
||||
|
||||
# Frontend local packages/archives/docs (non-source)
|
||||
frontend-web/pnpm-lock.yaml
|
||||
@@ -98,8 +96,6 @@ 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.
|
||||
@@ -242,8 +238,5 @@ offline-backend-20260512/backend/uv.lock
|
||||
# Python bytecode cache
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# Local Firefox 52 test profiles
|
||||
.firefox52-profile*/
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -100,7 +100,8 @@ 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 v4 + shadcn-style components in `src/components/ui/`
|
||||
- **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)。
|
||||
- **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
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
# 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
|
||||
@@ -117,7 +82,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` 是地图网页模式读取的瓦片模板。设置 → 外观的管理员选项「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。
|
||||
- **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。
|
||||
- **定时任务「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`.
|
||||
@@ -141,10 +106,6 @@ 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
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# 本地问答、资料库与技能并发验收
|
||||
|
||||
测试日期: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-28 补充高并发运行配置)。
|
||||
日期:2026-09-27。
|
||||
|
||||
本说明对应本次提交的修改,包含模型流式兼容与恢复、React #185 错误处理和已完成的最小技能示例,
|
||||
以及可选的技能视图复用、版本缓存、模型计时日志队列和 Gateway 线程池扩容。
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- **后台代码也要更新**:四个新开关需要新版后台读取。只换前端 dist 或只把字段写进旧版 config.yaml 不会实现后台优化。
|
||||
- **前端**:使用本说明末尾的 ZIP,里面是完整 `dist/`,不是源码或 node_modules。
|
||||
- **仓库运行配置已开启优化**:两份 config.yaml 的四个开关均已显式设为 `true`。但已有服务器的持久化/外挂配置不会被仓库自动覆盖,仍需手工同步。
|
||||
- **默认不启用优化**:仓库两份 config.yaml 的四个新开关均为 `false`。只有主动改为 `true` 才启用对应项。
|
||||
- 开启线程池开关后可将上限调整为 100,但不提高模型服务商配额、不启用多 worker。
|
||||
|
||||
## 2. .env 需要修改哪些项
|
||||
@@ -54,9 +54,8 @@ 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:
|
||||
@@ -93,7 +92,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. 开启后做了什么,没做什么
|
||||
@@ -113,9 +112,6 @@ runtime_performance:
|
||||
没有采用会延迟下线的 TTL 缓存;下一次工具调用能发现下线或无效配置并拒绝访问。
|
||||
已经开始执行的工具不因此被强行中断。
|
||||
缓存仅用于标准 `LocalSkillStorage`,自定义存储实现/子类保守回到原刷新路径。
|
||||
同一 Gateway 进程内,仅在多个热命中调用真正重叠时合并为一次元数据校验;
|
||||
该批调用结束后结果立即丢弃,新调用仍重新校验。启停文件哈希不同的调用不会加入旧校验,
|
||||
校验异常会对该批调用统一 fail-closed,且不会阻止后续调用重试。
|
||||
|
||||
热路径仍有 O(文件数) 元数据校验,慢磁盘/网络挂载仍可能耗时;这不是“所有文件操作零开销”,
|
||||
也没有把受锁保护的重建变成无锁写入。
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# 问答停在执行命令或读取技能:排查与修复
|
||||
|
||||
日期: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 未连接实际容器。未用生产模型调用或真实业务技能执行验证。
|
||||
@@ -1,52 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" suppressHydrationWarning data-browser-compat="firefox52">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute("data-browser-compat", "firefox52");
|
||||
var theme = localStorage.getItem("strategy-theme");
|
||||
root.classList.remove("dark", "light");
|
||||
if (theme !== "light") root.classList.add("dark");
|
||||
} catch (error) {}
|
||||
})();
|
||||
(function () {
|
||||
try {
|
||||
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
|
||||
return;
|
||||
}
|
||||
AbortSignal.timeout = function (milliseconds) {
|
||||
var controller = new AbortController();
|
||||
var timer = setTimeout(function () {
|
||||
var reason;
|
||||
try {
|
||||
reason = new DOMException("signal timed out", "TimeoutError");
|
||||
} catch (error) {
|
||||
reason = new Error("signal timed out");
|
||||
reason.name = "TimeoutError";
|
||||
}
|
||||
try {
|
||||
controller.abort(reason);
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
}
|
||||
}, milliseconds);
|
||||
controller.signal.addEventListener("abort", function () {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
return controller.signal;
|
||||
};
|
||||
} catch (error) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>CM 助手</title>
|
||||
<script src="/runtime-config.js" data-runtime-config></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,72 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const { transformSync } = require("@babel/core");
|
||||
const presetEnv = require("@babel/preset-env");
|
||||
|
||||
// Firefox 52 implements async functions, but async iteration arrived later.
|
||||
// Babel must therefore lower both async-generator declarations and `for await`
|
||||
// consumers. Leaving the latter to SWC turns them into a synchronous
|
||||
// `value[Symbol.iterator]()` loop and breaks LangGraph streams at runtime.
|
||||
const ASYNC_ITERATION_RE =
|
||||
/async\s+(?:function\s*)?\*|\bfor\s+await\s*\(/;
|
||||
|
||||
module.exports = function firefox52LegacyGeneratorLoader(source, inputMap) {
|
||||
if (!ASYNC_ITERATION_RE.test(source)) {
|
||||
this.callback(null, source, inputMap);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cacheable?.(true);
|
||||
const common = {
|
||||
filename: this.resourcePath,
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
sourceType: "unambiguous",
|
||||
sourceMaps: false,
|
||||
compact: false,
|
||||
};
|
||||
|
||||
// Babel's regenerator transform cannot consume several modern expressions
|
||||
// inside async generators in one pass. Lower those expressions first while
|
||||
// deliberately preserving async/generator syntax, then run the Firefox 52
|
||||
// pass. Only 24 LangChain/LangGraph modules currently enter this slow path.
|
||||
const normalized = transformSync(source, {
|
||||
...common,
|
||||
presets: [
|
||||
[
|
||||
presetEnv,
|
||||
{
|
||||
targets: { ie: "11" },
|
||||
bugfixes: true,
|
||||
modules: false,
|
||||
useBuiltIns: false,
|
||||
exclude: [
|
||||
"@babel/plugin-transform-async-generator-functions",
|
||||
"@babel/plugin-transform-async-to-generator",
|
||||
"@babel/plugin-transform-regenerator",
|
||||
// Preserve `for await` for the second pass. Transforming it as a
|
||||
// plain `for of` here loses async-iterator semantics permanently.
|
||||
"@babel/plugin-transform-for-of",
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const result = transformSync(normalized?.code ?? source, {
|
||||
...common,
|
||||
presets: [
|
||||
[
|
||||
presetEnv,
|
||||
{
|
||||
targets: { firefox: "52" },
|
||||
bugfixes: true,
|
||||
modules: false,
|
||||
useBuiltIns: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
this.callback(null, result?.code ?? normalized?.code ?? source, inputMap);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const loader = require("./legacy-generator-loader.cjs");
|
||||
|
||||
function compile(source) {
|
||||
let output;
|
||||
let thrown;
|
||||
loader.call(
|
||||
{
|
||||
resourcePath: "langgraph-sdk/dist/ui/manager.js",
|
||||
cacheable() {},
|
||||
callback(error, code) {
|
||||
thrown = error;
|
||||
output = code;
|
||||
},
|
||||
},
|
||||
source,
|
||||
undefined,
|
||||
);
|
||||
if (thrown) throw thrown;
|
||||
return output;
|
||||
}
|
||||
|
||||
test("keeps for-await asynchronous while lowering it for Firefox 52", () => {
|
||||
const output = compile(`
|
||||
export async function consume(run) {
|
||||
for await (const value of run) {
|
||||
consumeValue(value);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
assert.match(output, /_asyncIterator\(run\)/);
|
||||
assert.doesNotMatch(output, /run\[Symbol\.iterator\]/);
|
||||
assert.doesNotMatch(output, /for\s+await/);
|
||||
});
|
||||
|
||||
test("passes ordinary modules through without invoking Babel", () => {
|
||||
const source = "export const value = 42;";
|
||||
assert.equal(compile(source), source);
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import autoprefixer from "autoprefixer";
|
||||
import firefox52RgbVars from "../../scripts/postcss-firefox52-rgb-vars.mjs";
|
||||
import firefox52Where from "../../scripts/postcss-firefox52-where.mjs";
|
||||
|
||||
export default {
|
||||
plugins: [firefox52RgbVars, firefox52Where, autoprefixer],
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import tailwindcss from "tailwindcss";
|
||||
import darkDescendant from "../../scripts/postcss-dark-descendant.mjs";
|
||||
import firefox52RgbVars from "../../scripts/postcss-firefox52-rgb-vars.mjs";
|
||||
import firefox52Where from "../../scripts/postcss-firefox52-where.mjs";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss,
|
||||
darkDescendant,
|
||||
firefox52RgbVars,
|
||||
firefox52Where,
|
||||
autoprefixer,
|
||||
],
|
||||
};
|
||||
@@ -1,229 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
function replaceRequired(source, search, replacement, label) {
|
||||
const result = source.split(search).join(replacement);
|
||||
if (result === source) {
|
||||
throw new Error(`Firefox 52 compatibility patch no longer matches ${label}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = function firefox52SourceCompatLoader(source) {
|
||||
this.cacheable?.(true);
|
||||
const id = this.resourcePath.replaceAll("\\", "/");
|
||||
|
||||
if (/\/p-queue\/dist\/index\.js$/.test(id) && source.includes("#idAssigner = 1n;")) {
|
||||
return replaceRequired(source, "#idAssigner = 1n;", "#idAssigner = 1;", "p-queue");
|
||||
}
|
||||
|
||||
if (/\/mdast-util-gfm-autolink-literal\/lib\/index\.js$/.test(id)) {
|
||||
return replaceRequired(
|
||||
source,
|
||||
"(?<=^|\\s|\\p{P}|\\p{S})",
|
||||
"",
|
||||
"mdast-util-gfm-autolink-literal",
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes("/oniguruma-parser/")) {
|
||||
return source
|
||||
.split("\\p{Alpha}\\p{Pc}").join("A-Za-z_")
|
||||
.split("\\p{AHex}").join("[A-Fa-f0-9]")
|
||||
.split("\\p{Alpha}").join("[A-Za-z]")
|
||||
.split('"gsu"').join('"gu"');
|
||||
}
|
||||
|
||||
if (/\/node_modules\/mermaid\/dist\/chunks\/.*\.mjs$/.test(id)) {
|
||||
// Mermaid's generated Jison lexers put `break` immediately after `return`.
|
||||
// Firefox 52 reports every one as an unreachable-code warning. Removing
|
||||
// those dead statements is semantics-preserving and keeps DevTools usable.
|
||||
return source.replace(
|
||||
/(\breturn(?:\s+[^;\r\n]+)?;)(\r?\n[ \t]*)break;/g,
|
||||
"$1$2",
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/web-streams-polyfill\/dist\/ponyfill\.es5\.mjs$/.test(id)) {
|
||||
// The distributed ES5 ponyfill declares a local variable after an
|
||||
// unconditional return. Move the declaration before the return so Firefox
|
||||
// does not report it as unreachable while preserving its function scope.
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"function xt(e){return",
|
||||
"function xt(e){var o;return",
|
||||
"web-streams ponyfill local declaration",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
";var o}function Yt",
|
||||
";}function Yt",
|
||||
"web-streams ponyfill unreachable declaration",
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/node_modules\/@xyflow\/react\/dist\/esm\/index\.(?:js|mjs)$/.test(id)) {
|
||||
// React Flow 12.12 uses BigInt only as a monotonically increasing render
|
||||
// serial. Firefox 52 has no BigInt; Number preserves the queue semantics
|
||||
// because this counter cannot approach MAX_SAFE_INTEGER in one session.
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"useState(BigInt(0))",
|
||||
"useState(0)",
|
||||
"@xyflow/react queue serial initializer",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
"setSerial(n => n + BigInt(1))",
|
||||
"setSerial(n => n + 1)",
|
||||
"@xyflow/react queue serial increment",
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes("/node_modules/react-router/")) {
|
||||
let result = source;
|
||||
if (result.includes("async function loadRouteModule(")) {
|
||||
const start = result.indexOf("async function loadRouteModule(");
|
||||
const endMarker = "\n\n// lib/dom/ssr/links.ts";
|
||||
const end = result.indexOf(endMarker, start);
|
||||
if (end < 0) {
|
||||
throw new Error("Firefox 52 compatibility patch no longer matches React Router route modules");
|
||||
}
|
||||
const compatible = `async function loadRouteModule(route, routeModulesCache) {
|
||||
if (route.id in routeModulesCache) {
|
||||
return routeModulesCache[route.id];
|
||||
}
|
||||
throw new Error("External React Router route modules require native dynamic import and are unavailable in Firefox 52");
|
||||
}`;
|
||||
result = result.slice(0, start) + compatible + result.slice(end);
|
||||
}
|
||||
if (result.includes("function prefetchModule(modulePath)")) {
|
||||
result = replaceRequired(
|
||||
result,
|
||||
`function prefetchModule(modulePath) {
|
||||
import(
|
||||
/* @vite-ignore */
|
||||
/* webpackIgnore: true */
|
||||
modulePath
|
||||
);
|
||||
}`,
|
||||
`function prefetchModule(modulePath) {
|
||||
return Promise.resolve(modulePath);
|
||||
}`,
|
||||
"React Router module prefetch",
|
||||
);
|
||||
}
|
||||
const dynamicImportPattern = /import\(\s*\/\* @vite-ignore \*\/\s*\/\* webpackIgnore: true \*\/\s*/g;
|
||||
const withoutIgnoredImports = result.replace(
|
||||
dynamicImportPattern,
|
||||
"firefox52UnsupportedDynamicImport(",
|
||||
);
|
||||
if (withoutIgnoredImports !== result) {
|
||||
result = `${withoutIgnoredImports}
|
||||
|
||||
function firefox52UnsupportedDynamicImport(modulePath) {
|
||||
return Promise.reject(new Error(
|
||||
"External React Router module " + modulePath + " requires native dynamic import",
|
||||
));
|
||||
}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (/\/node_modules\/zod\/v4\/core\/util\.js$/.test(id)) {
|
||||
return source
|
||||
.split('int64: [/* @__PURE__*/ BigInt("-9223372036854775808"), /* @__PURE__*/ BigInt("9223372036854775807")],')
|
||||
.join("int64: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],")
|
||||
.split('uint64: [/* @__PURE__*/ BigInt(0), /* @__PURE__*/ BigInt("18446744073709551615")],')
|
||||
.join("uint64: [0, Number.MAX_SAFE_INTEGER],");
|
||||
}
|
||||
|
||||
if (/\/langsmith\/dist\/_openapi_client\/internal\/utils\/path\.(?:js|cjs)$/.test(id)) {
|
||||
const original = `const invalidSegmentPattern = /(?<=^|\\/)(?:\\.|%2e){1,2}(?=\\/|$)/gi;
|
||||
let match;
|
||||
// Find all invalid segments
|
||||
while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) {
|
||||
invalidSegments.push({
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
error: \`Value "\${match[0]}" can\\'t be safely passed as a path parameter\`,
|
||||
});
|
||||
}`;
|
||||
const compatible = `const invalidSegmentPattern = /(?:^|\\/)((?:\\.|%2e){1,2})(?=\\/|$)/gi;
|
||||
let match;
|
||||
// Firefox 52 has no RegExp lookbehind. Keep the separator in the full
|
||||
// match and report only the captured path segment.
|
||||
while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) {
|
||||
const segment = match[1];
|
||||
invalidSegments.push({
|
||||
start: match.index + match[0].length - segment.length,
|
||||
length: segment.length,
|
||||
error: \`Value "\${segment}" can\\'t be safely passed as a path parameter\`,
|
||||
});
|
||||
}`;
|
||||
return replaceRequired(source, original, compatible, "langsmith path validation");
|
||||
}
|
||||
|
||||
if (/\/langsmith\/dist\/utils\/sampling\.js$/.test(id)) {
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"const SAMPLING_HASH_MODULUS = 1000000n;",
|
||||
'const SAMPLING_HASH_MODULUS = typeof BigInt === "function" ? BigInt(1000000) : 1000000;',
|
||||
"langsmith sampling modulus",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
"const bucket = XXH3_128(encoder.encode(identifier.toLowerCase())) % SAMPLING_HASH_MODULUS;\n return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate;",
|
||||
`const hash = XXH3_128(encoder.encode(identifier.toLowerCase()));
|
||||
const bucket = hash instanceof Uint8Array
|
||||
? new DataView(hash.buffer, hash.byteOffset, hash.byteLength).getUint32(0, false) % 1000000
|
||||
: hash % SAMPLING_HASH_MODULUS;
|
||||
return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate;`,
|
||||
"langsmith sampling fallback",
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/langsmith\/dist\/utils\/xxhash\/xxhash\.js$/.test(id)) {
|
||||
const fallback = `
|
||||
function hash128WithoutBigInt(data) {
|
||||
const states = [2166136261, 2246822507, 3266489909, 668265263];
|
||||
const primes = [16777619, 3266489917, 668265263, 374761393];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const value = data[i] + i;
|
||||
for (let j = 0; j < states.length; j++) {
|
||||
states[j] = Math.imul(states[j] ^ (value + j * 97), primes[j]) >>> 0;
|
||||
states[j] = (states[j] ^ (states[j] >>> 13)) >>> 0;
|
||||
}
|
||||
}
|
||||
const result = new Uint8Array(16);
|
||||
const view = new DataView(result.buffer);
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
let value = states[i] ^ data.length;
|
||||
value = Math.imul(value ^ (value >>> 16), 2246822507) >>> 0;
|
||||
value = Math.imul(value ^ (value >>> 13), 3266489909) >>> 0;
|
||||
view.setUint32(i * 4, (value ^ (value >>> 16)) >>> 0, false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
`;
|
||||
let result = replaceRequired(
|
||||
source,
|
||||
"const n = (n) => BigInt(n);",
|
||||
'const n = (value) => typeof BigInt === "function" ? BigInt(value) : 0;',
|
||||
"langsmith BigInt initializer",
|
||||
);
|
||||
result = replaceRequired(
|
||||
result,
|
||||
"export function XXH3_128(data, seed = n(0)) {",
|
||||
`${fallback}\nexport function XXH3_128(data, seed = n(0)) {\n if (typeof BigInt !== "function" || typeof BigUint64Array !== "function" || typeof DataView.prototype.getBigUint64 !== "function") {\n return hash128WithoutBigInt(data);\n }`,
|
||||
"langsmith XXH3 fallback",
|
||||
);
|
||||
return replaceRequired(
|
||||
result,
|
||||
"export function xxh128ToBytes(hash128) {",
|
||||
"export function xxh128ToBytes(hash128) {\n if (hash128 instanceof Uint8Array) return hash128;",
|
||||
"langsmith byte conversion",
|
||||
);
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const loader = require("./source-compat-loader.cjs");
|
||||
|
||||
function compile(source, resourcePath) {
|
||||
return loader.call({ resourcePath, cacheable() {} }, source);
|
||||
}
|
||||
|
||||
test("replaces only XYFlow's render serial BigInt operations", () => {
|
||||
const output = compile(
|
||||
"const [serial, setSerial] = useState(BigInt(0)); setSerial(n => n + BigInt(1));",
|
||||
"C:/repo/node_modules/@xyflow/react/dist/esm/index.js",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
output,
|
||||
"const [serial, setSerial] = useState(0); setSerial(n => n + 1);",
|
||||
);
|
||||
});
|
||||
|
||||
test("fails when the pinned XYFlow source no longer matches", () => {
|
||||
assert.throws(
|
||||
() => compile("const serial = 0;", "C:/repo/node_modules/@xyflow/react/dist/esm/index.js"),
|
||||
/compatibility patch no longer matches @xyflow\/react queue serial initializer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("removes Mermaid Jison breaks that Firefox reports as unreachable", () => {
|
||||
const output = compile(
|
||||
'switch (rule) {\n case 1:\n return "TEXT";\n break;\n case 2:\n return 2;\n break;\n}',
|
||||
"C:/repo/node_modules/mermaid/dist/chunks/mermaid.core/flow.mjs",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(output, /break;/);
|
||||
assert.match(output, /return "TEXT";/);
|
||||
assert.match(output, /return 2;/);
|
||||
});
|
||||
|
||||
test("moves the web streams ponyfill declaration before its return", () => {
|
||||
const output = compile(
|
||||
"function xt(e){return make(e);var o}function Yt(e){return e}",
|
||||
"C:/repo/node_modules/web-streams-polyfill/dist/ponyfill.es5.mjs",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
output,
|
||||
"function xt(e){var o;return make(e);}function Yt(e){return e}",
|
||||
);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
# Firefox 52 第三方依赖兼容清单
|
||||
|
||||
本文记录 `fix/firefox52-compat` 分支中会影响 Firefox 52 的依赖、处理方式和回归入口。版本以 2026-10-09 的 `pnpm-lock.yaml` 实际解析结果为准;升级表内依赖后必须重新执行文末检查。
|
||||
|
||||
## 分级
|
||||
|
||||
| 等级 | 含义 | 当前处理 |
|
||||
|---|---|---|
|
||||
| A | 原生可运行,或由项目统一 SWC 转换即可运行 | 使用 Rspack 的 Firefox 52 单目标转换 |
|
||||
| B | 语法可转换,但缺少浏览器 API | 在 `src/core/compat/firefox52.ts` 做能力检测后安装 polyfill |
|
||||
| C | 依赖源码含 async iterator 等需要额外 Babel 转换的语法 | 精确 allowlist 后交给专用 loader |
|
||||
| D | 含 BigInt、危险正则或平台实现差异,普通转译不能解决 | 固定版本并做带断言的精确源码替换或替代实现 |
|
||||
| E | Firefox 52 网络层不提供现代流式 fetch 等能力 | 提供保持业务协议的运行时回退 |
|
||||
|
||||
## 已验证依赖
|
||||
|
||||
| 依赖/能力 | 实际版本 | 等级 | 处理与验证 |
|
||||
|---|---:|:---:|---|
|
||||
| React / React DOM | 18.3.1 | A | 兼容分支从 React 19 降为 18.3;所有页面统一使用这一运行时。118 条路由矩阵用于发现 Provider/Hook 运行时问题。 |
|
||||
| React Router DOM | 7.18.4 | D | `source-compat-loader.cjs` 处理动态路由模块加载差异;Hash 路由、重定向、刷新、公开页和 iframe 均进入完整路由冒烟。 |
|
||||
| Tailwind CSS | 3.4.19 | A | 采用 Tailwind 3 生成静态 utility;PostCSS 展平 `@layer`,转换 `:where()`、暗色后代规则和 CSS 变量透明色。 |
|
||||
| Rspack | 2.2.8 | A | `target: ["web", "es5"]`,SWC/Lightning CSS 的目标均为 Firefox 52;使用普通 script 与 Rspack chunk loader。 |
|
||||
| `@langchain/core` | 1.2.17 | C | LangChain 包进入 async-iteration loader;真实问答验证 thread 创建、XHR 流、导航和最终回复。 |
|
||||
| `@langchain/langgraph-sdk` | 1.12.3 | C | 对 async generator 和普通 async 方法中的 `for await` 做两阶段 Babel 转换;回归测试防止退化为同步 `Symbol.iterator`。 |
|
||||
| LangSmith | 0.10.8 | D | 进入源码兼容 loader,替换 Firefox 52 不支持的 BigInt/哈希实现路径;产物静态扫描兜底。 |
|
||||
| `p-queue` | 6.6.2 / 9.3.3 | D | 精确处理 BigInt 并发计数;loader 找不到预期源码时直接让构建失败,避免静默输出危险代码。 |
|
||||
| React Flow (`@xyflow/react`) | 12.12.0 | D | 仅把内部渲染序号的 `BigInt(0)` 和递增改为 Number;流程图相关路由已在 Firefox 52 中加载。 |
|
||||
| Shiki | 3.15.0 | D | 重型模块按需分块;其 Oniguruma JS 引擎进入下面的正则兼容处理。 |
|
||||
| Oniguruma parser | 0.12.2 | D | 移除 Firefox 52 不支持且不改变当前匹配语义的正则 flag;代码高亮页面与产物语法检查共同验证。 |
|
||||
| Mermaid | 11.17.2 | C | 由 Rspack/SWC 转换并按需加载;Markdown/研究/会商入口纳入路由矩阵。 |
|
||||
| Zod | 3.24.x(直接依赖) | D | 依赖源码进入兼容 loader,处理 BigInt 上下界;扫描最终 chunks 是否残留危险语法。 |
|
||||
| MapLibre GL | 4.7.1 | D | 固定在可转换版本;保持 Leaflet/静态信息回退,避免升级后引入 Firefox 52 无法执行的 Worker/语法。 |
|
||||
| CodeMirror / Tiptap / KaTeX / Marked | 锁文件版本 | A/C | 由 SWC 统一转换并路由级加载;Markdown 编辑器、预览和 Word 导出均有真实浏览器冒烟。 |
|
||||
| Radix UI / TDesign / cmdk | 锁文件版本 | A/B | Pointer Events、ResizeObserver、Portal 点击和滚动锁由兼容入口及分层 CSS 处理;菜单、选择器、弹窗、Tabs 有点击测试。 |
|
||||
| `docx` / `file-saver` | 9.5.1 / 2.0.5 | B | 正式 Word 由后端生成;Firefox 52 冒烟验证非空 DOCX Blob、MIME 和 `.docx` 下载文件名。 |
|
||||
| xlsx 解压 | fflate 0.8.3 | D | 浏览器端解压路径改为 fflate,避开原实现的现代能力依赖。 |
|
||||
|
||||
## 浏览器 API 与平台回退
|
||||
|
||||
`src/core/compat/firefox52.ts` 在应用入口最先加载,按能力补充 AbortController、AbortSignal、Observers、Web Streams、Pointer Events、`requestSubmit`、`scrollIntoView`、Clipboard、Blob/File、UUID 等能力。所有补丁先检查原生实现,现代浏览器不会被无条件覆盖。
|
||||
|
||||
Firefox 52 的 `fetch()` 没有可供业务消费的流式 `Response.body`。`src/core/compat/firefox52-fetch.ts` 用 `XMLHttpRequest.onprogress` 构造兼容的增量响应,保留请求头、鉴权、取消、错误状态和 SSE/NDJSON 分片语义。该路径属于 E 级平台回退,不能删除后只依赖 Web Streams polyfill。
|
||||
|
||||
## 升级规则
|
||||
|
||||
1. 修改表内版本后先运行 `pnpm test:firefox52-build`。精确源码片段不匹配会直接失败,此时需重新审计依赖实现,不能放宽成无条件替换。
|
||||
2. 执行 `pnpm build:firefox52 && pnpm check:firefox52`,确认最终 HTML、CSS、JS 与所有动态 chunks 都能被 Firefox 52 解析。
|
||||
3. 启动 Firefox 52 后执行 `pnpm smoke:firefox52:themes`、`pnpm smoke:firefox52:live` 和 `pnpm smoke:firefox52:full`。
|
||||
4. LangChain、LangGraph、LangSmith、p-queue、React Router、XYFlow、Oniguruma、Shiki、Mermaid、MapLibre 任一升级,都必须重新跑真实流式问答和相关懒加载路由,不能只看构建成功。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- WebGL 的最终可用性还受显卡、远程桌面和驱动影响;无 WebGL 环境应走现有的二维或文本信息回退。
|
||||
- `1366×768`、`1920×1080`、`2560×1440` 的精确视觉截图需要对应大小的真实显示环境。当前自动化会读取真实 DOM 尺寸和样式,但不会伪造操作系统无法提供的物理视口。
|
||||
- 创建、删除、发布、回滚等会修改业务数据的管理操作不在自动点击矩阵中;这些页面会验证加载、可见 Tabs 与未捕获异常,数据写入流程由专门测试数据验收。
|
||||
@@ -1,56 +0,0 @@
|
||||
# Firefox 52 路由与交互验收说明
|
||||
|
||||
## 自动化范围
|
||||
|
||||
`pnpm smoke:firefox52:full` 直接连接 Firefox 52 的远程调试端口,并以管理员会话逐一访问前端路由。矩阵当前包含 118 个入口,覆盖:
|
||||
|
||||
- 登录、落地页、公开页和独立会商页;
|
||||
- 工作区、智能体、技能、AI 写作、深度研究、公文、企业研究和 Notebook;
|
||||
- 多智能体会商、业务链、报告协作和知识空间;
|
||||
- 定时任务、管理员页面、设置页面和模型相关页面;
|
||||
- 任务管理、任务分析、行动分析、任务会商、资源管理和轻应用;
|
||||
- canvas、notebook、strategy、iframe 与兼容重定向入口。
|
||||
|
||||
每条路由都会检查 URL/重定向、页面完整加载、根节点尺寸、可见内容、错误边界、未捕获 `PageError`、控制台错误和 chunk 加载错误。页面内所有可见且未禁用的 `[role="tab"]` 会依次点击,以触发非破坏性的懒加载面板。脚本不会点击保存、删除、发布、回滚等会修改业务数据的按钮。
|
||||
|
||||
默认使用应用正常的 SPA 导航。`FIREFOX_FULL_RELOAD=1` 可切换为逐路由整页刷新,建议配合 `FIREFOX_ROUTE_FILTER` 分批运行。Firefox 52 在远程调试器、开发 source map 和上百次连续整页刷新同时存在时会积累调试内存;默认模式可以覆盖全部入口,同时避免把调试器内存压力误判为某个页面故障。
|
||||
|
||||
## 核心交互与专项
|
||||
|
||||
`pnpm smoke:firefox52` 当前包含 20 项真实浏览器检查:
|
||||
|
||||
- 7 个核心页面加载;
|
||||
- 左下角“设置和更多”、换肤色板;
|
||||
- Agent、模型和常用提示词弹窗;
|
||||
- 输入框 Enter 提交;
|
||||
- 对话类型筛选、项目创建模式;
|
||||
- AI 写作模型、会商业务链、定时任务新建弹窗;
|
||||
- Markdown 编辑器重置;
|
||||
- 调用正式后端接口生成非空 DOCX Blob,并校验 MIME 与 `.docx` 文件名。
|
||||
|
||||
`pnpm smoke:firefox52:live` 在上述基础上执行一轮真实问答。它验证表单提交、线程创建、路由跳转、XHR 增量流式读取和包含唯一校验标记的助手回复。`pnpm smoke:firefox52:themes` 验证六套主题乘以全量/简洁布局,共 12 种组合。
|
||||
|
||||
## 运行方式
|
||||
|
||||
```powershell
|
||||
$env:FIREFOX_DEBUG_PORT = "6001"
|
||||
pnpm smoke:firefox52
|
||||
pnpm smoke:firefox52:live
|
||||
pnpm smoke:firefox52:themes
|
||||
pnpm smoke:firefox52:full
|
||||
```
|
||||
|
||||
可用 `FIREFOX_ROUTE_FILTER` 只回归匹配的路由;冷编译较重的页面可通过 `FIREFOX_ROUTE_TIMEOUT_MS` 调整单页等待时间,默认 30 秒。测试账号默认 `admin`,可用 `FIREFOX_TEST_USERNAME` 和 `FIREFOX_TEST_PASSWORD` 覆盖。
|
||||
|
||||
## 2026-10-09 结果
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| 核心页面与交互 | 20/20 通过 |
|
||||
| 真实流式问答 | 1/1 通过 |
|
||||
| 真实智能体流式问答 | 1/1 通过 |
|
||||
| 主题 × 布局 | 12/12 通过 |
|
||||
| 开发站点完整路由 | 118/118 通过,并点击 71 个可见非破坏性 Tabs |
|
||||
| 生产预览完整路由 | 118/118 通过,并点击 74 个可见非破坏性 Tabs |
|
||||
|
||||
验收结论仅覆盖本次账号和现有测试数据。需要特定外部系统、任务深链参数、硬件 WebGL 或实际业务数据写入的流程,仍应在对应部署环境补充人工验收。
|
||||
@@ -1,579 +0,0 @@
|
||||
# Firefox 52 全功能兼容与 Rspack 高性能构建开发计划
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 目标分支 | `fix/firefox52-compat` |
|
||||
| 独立工作区 | `F:\react01\firefox-web` |
|
||||
| 前端目录 | `F:\react01\firefox-web\frontend-web` |
|
||||
| 目标浏览器 | Firefox 52(Windows x64) |
|
||||
| 技术方向 | React 18.3 + Tailwind CSS 3.4 + Rspack 2.x + 内置 SWC |
|
||||
| 核心目标 | 尽可能保持现有全部功能,提高冷构建和增量构建速度,保证主要页面与主题样式 |
|
||||
| 当前状态 | 兼容构建与自动回归已接通:核心交互 20/20、主题布局 12/12、完整路由 118/118,真实普通问答与智能体问答均通过 |
|
||||
|
||||
### 1.1 2026-10-09 实施进度
|
||||
|
||||
已完成:
|
||||
|
||||
- 建立 Firefox 52 专用 Rspack/SWC 单目标构建,开发服务器、快速构建、正式构建和预览命令均已接通;持久化缓存生效。当前机器实测冷生产构建 16.865 秒,首轮后的四次稳态构建中位数 1.584 秒、P95 1.641 秒。
|
||||
- 运行时降级到 React 18.3;Tailwind CSS 采用 `feat/tailwind-v3-legacy-chrome` 已验证方案降级到 3.4,保留现有主题 token 和组件类名。
|
||||
- 建立统一兼容入口,补齐 Pointer Events、`requestSubmit`、`scrollIntoView({block:"nearest"})`、Blob、UUID、Clipboard、ResizeObserver、Web Streams 等能力。
|
||||
- 流式请求使用 XHR `onprogress` 适配 SSE/NDJSON/Range 等接口;单元测试 4/4 通过。
|
||||
- 修复 Firefox 52 下的 SPA 路由白屏、`display: contents` 根布局失效、侧边栏分隔线颜色、首页宽度 token、欢迎标题透明、常用提示词换行、Radix 菜单无法点击、cmdk 下拉框崩溃和 Enter 无法发送。
|
||||
- CSS 兼容样式拆到 `src/styles/firefox52/`;PostCSS 自动转换 `rgb(var(...) / alpha)`、`:where()` 和暗色后代选择器,并为 flex gap 提供回退。
|
||||
- MapLibre 固定到 4.7.1,xlsx 解压改用 fflate;对 LangChain/LangGraph/LangSmith、Oniguruma、React Router、Zod 等依赖启用精确转换或源码兼容 loader。
|
||||
- 修复 LangGraph 普通 async 方法中 `for await` 被错误降为同步迭代的问题,并增加 loader 回归测试;修复 XYFlow 12.12 的 BigInt 渲染序号导致流程页面白屏的问题。
|
||||
- 增加 `check:firefox52-css`、`check:firefox52-js`、核心交互、真实流式、主题布局和 118 条完整路由脚本,用于阻止现代 CSS/JS 语法、路由和页面交互回归。
|
||||
- 建立 `docs/firefox52-dependency-compatibility.md` 和 `docs/firefox52-route-compatibility.md`,记录版本、补丁原因、验证入口与平台限制。
|
||||
|
||||
本阶段验证结果:
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|---|---|
|
||||
| Firefox 52 核心页面与交互冒烟 | 20/20 通过 |
|
||||
| Firefox 52 真实普通问答 | 线程创建、路由跳转、XHR 增量流与最终回复均通过 |
|
||||
| Firefox 52 真实智能体问答 | 自动选择可用智能体,线程创建、XHR 增量流与最终回复均通过 |
|
||||
| 主题与布局 | 6 套主题 × 全量/简洁布局,12/12 通过 |
|
||||
| Word 导出 | 后端实际生成非空 DOCX,MIME 与下载文件名通过 |
|
||||
| 完整路由 | 开发与生产预览均为 118/118;分别点击 71/74 个可见非破坏性 Tabs;强制刷新模式支持按过滤器分批运行 |
|
||||
| 关键交互 | 设置和更多、换肤、Agent/模型选择、提示词管理、Enter 提交、项目创建、业务链条、定时任务新建、Markdown 编辑器与 Word 下载 |
|
||||
| 正式产物 CSS 检查 | 通过 |
|
||||
| 正式产物 Acorn ES2017 解析 | 464/464,通过 |
|
||||
| Firefox 52 浏览器自身 `new Function` 解析 | 464/464,0 错误 |
|
||||
| 正式产物 JS 体积 | 25.2 MiB(464 个按需 chunk) |
|
||||
| Rspack 生产构建 | 冷构建 16.865 秒;稳态中位数 1.584 秒、P95 1.641 秒 |
|
||||
|
||||
管理员、任务、iframe、公开页、canvas、会商和设置等静态入口已纳入完整路由矩阵。需要特定任务参数、外部系统、业务数据写入、硬件 WebGL 或精确物理分辨率的流程,仍需在对应部署环境补充人工验收,不能用静态入口通过替代。
|
||||
|
||||
## 2. 建设目标
|
||||
|
||||
本项目在独立兼容分支中建设 Firefox 52 专用前端构建链。现代浏览器版本继续在主开发目录使用现有 Vite 构建,兼容分支只生成 Firefox 52 单目标产物,不生成现代与旧版两套 bundle。
|
||||
|
||||
开发遵循以下目标:
|
||||
|
||||
1. **功能覆盖优先**:现有路由和主要业务流程都必须进入兼容验收矩阵,不能以“首页能够打开”作为完成标准。
|
||||
2. **构建性能优先**:使用 Rspack、SWC、增量编译和持久化缓存,移除 legacy bundle 的二次 Babel/SystemJS 转换。
|
||||
3. **样式一致性优先**:保留 Tailwind、TDesign、Radix 和自定义主题,系统处理 Firefox 52 不支持的 CSS 语法与布局能力。
|
||||
4. **兼容逻辑可维护**:浏览器差异集中到构建配置、兼容入口、传输适配器和依赖补丁,不在业务组件中散落版本判断。
|
||||
5. **持续跟进主分支**:兼容分支能够定期合并 `master-zg` 的新功能,并通过自动检查发现新增的不兼容依赖和语法。
|
||||
|
||||
## 3. 完成标准
|
||||
|
||||
### 3.1 功能标准
|
||||
|
||||
- Firefox 52 打开登录页、首页和工作区时没有白屏。
|
||||
- 首屏与路由切换没有未捕获 JavaScript 异常、语法错误和 chunk 加载错误。
|
||||
- 功能矩阵中的 P0、P1 场景全部通过;P2 场景逐项验证并记录结果。
|
||||
- 无法由旧浏览器实现的原生能力必须提供等价回退,页面和主流程不能直接不可用。
|
||||
- 文件上传、文件下载、流式问答、Markdown、公式、代码块、流程图和 Word 导出必须作为专项验收项。
|
||||
- iframe、任务深链、公开分享页面和运行时配置必须保持现有 URL 与接口行为。
|
||||
|
||||
### 3.2 性能标准
|
||||
|
||||
先在阶段 0 获取当前 Vite legacy 的基线,后续使用相同机器、相同源码、相同依赖和相同环境重复测试。最终门槛如下:
|
||||
|
||||
| 指标 | 目标 |
|
||||
|---|---|
|
||||
| 开发服务器首次可访问时间 | 不高于基线的 50%,期望不超过 10 秒 |
|
||||
| 修改单个 TSX 后的增量构建 | P95 不超过 1.5 秒 |
|
||||
| 修改全局 CSS 后的增量构建 | P95 不超过 2 秒 |
|
||||
| 无缓存生产构建 | 不高于基线的 50% |
|
||||
| 有缓存生产构建 | 不高于基线的 30% |
|
||||
| 连续构建稳定性 | 连续 10 次无随机失败、无失效 chunk、无内存溢出 |
|
||||
| 产物浏览器检查 | 不含 Firefox 52 无法解析的模块入口和已知危险语法 |
|
||||
|
||||
绝对时间与相对提升发生冲突时,以“功能正确、样式正确、相对基线明显提升”为判断顺序。未达到性能门槛前不移除旧构建链。
|
||||
|
||||
### 3.3 样式标准
|
||||
|
||||
- 全量模式、简洁模式、嵌入模式均无整体布局塌陷。
|
||||
- 浅色、深色、浅蓝、浅绿、冰蓝、浅黄等现有主题逐一验证。
|
||||
- 重点分辨率至少覆盖 `1366×768`、`1920×1080`、`2560×1440`。
|
||||
- 导航栏、侧边栏、表单、表格、标签页、弹窗、下拉框、Tooltip、抽屉和浮层没有明显错位或遮挡。
|
||||
- 页面不得出现因 `@layer`、现代颜色语法或不支持的选择器导致的整片无样式。
|
||||
- 主流程截图与现代浏览器参考图保持结构、间距、颜色和层级一致;字体抗锯齿造成的细微像素差异不作为失败。
|
||||
|
||||
## 4. 当前兼容基线
|
||||
|
||||
兼容分支已经存在以下措施,Rspack 迁移必须先等价承接,再进行优化:
|
||||
|
||||
1. `@vitejs/plugin-legacy` 生成 Firefox 52 的 SystemJS legacy bundle。
|
||||
2. Mermaid legacy chunk 额外经过 Babel 转换。
|
||||
3. 对 `p-queue`、GFM autolink、Oniguruma parser、LangSmith XXHash 进行构建期源码补丁。
|
||||
4. 入口安装 AbortController、IntersectionObserver、ResizeObserver 和 Web Streams polyfill。
|
||||
5. PostCSS 展平 Tailwind `@layer`。
|
||||
6. 自定义颜色回退和 Lightning CSS 目标转换。
|
||||
7. 全局样式中已经维护主题对应的 legacy 色值。
|
||||
8. `index.html` 在应用入口前安装 `AbortSignal.timeout` 回退。
|
||||
|
||||
当前方案的主要问题:
|
||||
|
||||
- Vite 开发服务器依赖现代 ESM,Firefox 52 无法直接用于日常开发调试。
|
||||
- legacy 构建对最终 chunks 再执行 Babel 和 SystemJS 转换,模块量越大耗时越明显。
|
||||
- 依赖源码在构建过程中做字符串替换,缓存命中率、升级稳定性和错误定位都较差。
|
||||
- Web Streams polyfill只能补充 JavaScript API,不能自动让 Firefox 52 的 `fetch` 响应具备网络流式读取能力。
|
||||
- CSS 已有回退主要围绕旧版 Chrome 建设,还需要按 Firefox 52 实际渲染能力重新审计。
|
||||
|
||||
## 5. 总体技术方案
|
||||
|
||||
### 5.1 构建链
|
||||
|
||||
Firefox 52 专用构建使用以下组件:
|
||||
|
||||
- `@rspack/core`
|
||||
- `@rspack/cli`
|
||||
- `@rspack/dev-server`
|
||||
- `builtin:swc-loader`
|
||||
- Rspack SWC JavaScript minimizer
|
||||
- Rspack Lightning CSS minimizer或现有 PostCSS + Lightning CSS 流程
|
||||
- HTML 生成插件、资源复制插件和 React Refresh 集成(确认 Firefox 52 可运行后启用)
|
||||
|
||||
构建目标分成两层:
|
||||
|
||||
1. Rspack runtime 目标指定 Firefox 52,保证 chunk loader、bootstrap 和 HMR runtime 不输出危险语法。
|
||||
2. SWC `env.targets` 指定 Firefox 52,负责 TS、TSX、JSX 和业务 JavaScript 的语法降级及 core-js 注入。
|
||||
|
||||
生产构建仅输出一套 Firefox 52 产物,使用普通 script/JSONP chunk loading,不保留 Vite legacy 的 SystemJS 双阶段处理。
|
||||
|
||||
### 5.2 配置与资源保持
|
||||
|
||||
迁移时保持以下外部行为:
|
||||
|
||||
- `runtime-config.js` 继续在应用 bundle 之前加载。
|
||||
- 保持现有 `VITE_*` 配置键,不要求部署环境一次性改名。
|
||||
- `@/env`、`@/*`、`@vite/*`、`next/navigation`、`next/link` 等 alias 保持一致。
|
||||
- `base`、静态资源目录、favicon、字体和图片路径保持可配置。
|
||||
- Hash 路由、BrowserRouter 深层路由、iframe 嵌入路由和公开页面刷新都能正确加载资源。
|
||||
- 开发端口与启动脚本可以通过环境变量覆盖,默认不与主开发工作区冲突。
|
||||
|
||||
### 5.3 构建模式
|
||||
|
||||
计划提供以下命令:
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `npm run dev` / `pnpm dev` | 默认启动 Firefox 52 Rspack Dev Server,端口 5174 |
|
||||
| `pnpm dev:firefox52` | Rspack Dev Server,供 Firefox 52 直接调试 |
|
||||
| `npm run dev:vite` / `pnpm dev:vite` | 仅在现代浏览器调试时启动原 Vite Dev Server |
|
||||
| `pnpm build:firefox52` | Firefox 52 正式单目标构建 |
|
||||
| `pnpm build:firefox52:fast` | 不压缩、保留调试信息的快速验证构建 |
|
||||
| `pnpm preview:firefox52` | 使用静态服务器验证正式产物 |
|
||||
| `pnpm check:firefox52` | 扫描危险语法、资源路径和兼容清单 |
|
||||
| `pnpm benchmark:firefox52` | 执行冷构建、热构建和增量构建基准 |
|
||||
| `pnpm smoke:firefox52` | 运行 Firefox 52 核心页面与交互冒烟 |
|
||||
| `pnpm smoke:firefox52:live` | 运行真实普通问答流式冒烟 |
|
||||
| `pnpm smoke:firefox52:agent-live` | 选择当前可用智能体并运行真实流式冒烟 |
|
||||
| `pnpm smoke:firefox52:themes` | 验证 6 套主题 × 2 种布局 |
|
||||
| `pnpm smoke:firefox52:full` | 验证 118 个路由及可见非破坏性 Tabs |
|
||||
|
||||
类型检查继续单独运行 `pnpm typecheck`,不阻塞开发构建的模块转换。
|
||||
|
||||
## 6. JavaScript 与浏览器 API 兼容方案
|
||||
|
||||
### 6.1 兼容入口
|
||||
|
||||
建立唯一的 Firefox 52 兼容入口,并保证它早于 React、路由、请求客户端和业务模块执行。兼容入口统一处理:
|
||||
|
||||
- `Promise`、`Map`、`Set`、`Symbol`、迭代器及必要的 core-js 能力。
|
||||
- AbortController、AbortSignal.timeout 和 abort reason 差异。
|
||||
- ResizeObserver、IntersectionObserver、matchMedia 监听接口。
|
||||
- ReadableStream、WritableStream、TransformStream。
|
||||
- `queueMicrotask`、`globalThis`、`Object.fromEntries`、`Promise.finally` 等运行时能力。
|
||||
- `crypto.randomUUID`、Clipboard、下载、全屏等能力的回退实现。
|
||||
- `requestIdleCallback`、`structuredClone` 等组件可能间接使用的 API。
|
||||
|
||||
所有 polyfill 必须具备能力检测,只在缺失时安装,避免重复覆盖浏览器实现。
|
||||
|
||||
### 6.2 流式请求专项
|
||||
|
||||
流式问答是兼容工作的 P0 项。Firefox 52 的网络层不能依赖现代 `fetch().body.getReader()`,需建立统一传输适配器:
|
||||
|
||||
1. 现代浏览器继续使用现有 fetch/SSE/NDJSON 路径。
|
||||
2. Firefox 52 使用 XHR `onprogress` 增量读取 `responseText`。
|
||||
3. 适配器统一处理 SSE、NDJSON、取消、超时、断线、服务端错误和 UTF-8 分片。
|
||||
4. LangGraph、普通问答、智能体问答、AI 写作、深度研究、会商、技能地址扫描等流式接口全部复用同一兼容层。
|
||||
5. 增加分片边界测试,覆盖半个 JSON、半个 UTF-8 字符、连续空行、最后一行无换行和中途取消。
|
||||
|
||||
### 6.3 不可直接转换的语法
|
||||
|
||||
以下内容不能只依赖 SWC 或 core-js,必须通过升级、降级、替代或固定补丁解决:
|
||||
|
||||
- BigInt 字面量及 BigInt 专用算法。
|
||||
- 正则向后断言、Unicode property escapes 和不支持的正则 flag。
|
||||
- Module Worker 和依赖原生 ESM 的 Worker。
|
||||
- 运行时拼接后才生成的现代语法。
|
||||
- 强依赖新浏览器 Web API 的第三方组件。
|
||||
|
||||
处理顺序:优先选择兼容版本,其次维护专用 fork,再使用 `patch-package` 或安装后补丁。构建期间的逐文件字符串替换只作为临时迁移手段。
|
||||
|
||||
### 6.4 产物静态扫描
|
||||
|
||||
`check:firefox52` 至少扫描:
|
||||
|
||||
- 未被打包的 `import`、`export`、`import.meta`。
|
||||
- BigInt 字面量和 `BigInt64Array`/`BigUint64Array` 直接依赖。
|
||||
- 正则向后断言、Unicode property escapes、dotAll 等危险正则。
|
||||
- 未处理的 async generator、module worker 和 module script。
|
||||
- 绝对开发机路径、丢失的 chunk、错误 public path。
|
||||
- HTML 中会被 Firefox 52 忽略且没有回退的入口脚本。
|
||||
|
||||
扫描结果必须支持白名单,白名单需要注明依赖、原因、验证方式和负责人,不能静默忽略。
|
||||
|
||||
## 7. 第三方依赖治理
|
||||
|
||||
### 7.1 依赖分级
|
||||
|
||||
建立 `docs/firefox52-dependency-compatibility.md`,按以下类别维护:
|
||||
|
||||
| 等级 | 含义 | 处理方式 |
|
||||
|---|---|---|
|
||||
| A | 原生兼容或经 SWC 即可兼容 | 正常打包 |
|
||||
| B | 缺少 API polyfill | 加入统一兼容入口 |
|
||||
| C | 包含可转换的新语法 | 加入 SWC 依赖 allowlist |
|
||||
| D | 包含不可转换语法或新 API | 固定版本、fork、patch 或替换 |
|
||||
| E | Firefox 52 平台能力无法完整支持 | 提供业务等价回退并记录差异 |
|
||||
|
||||
### 7.2 首批专项依赖
|
||||
|
||||
优先审计以下依赖:
|
||||
|
||||
- React 19、React DOM、React Router。
|
||||
- LangGraph SDK、LangChain、AI SDK、LangSmith。
|
||||
- Mermaid、Shiki、react-syntax-highlighter、Oniguruma。
|
||||
- CodeMirror、Tiptap/ProseMirror、KaTeX、Marked、react-markdown。
|
||||
- MapLibre GL、Leaflet、XYFlow、OGL、GSAP、Motion。
|
||||
- Radix UI、TDesign React、cmdk、Embla。
|
||||
- TanStack Query、Axios、Zod、date-fns、uuid。
|
||||
- docx、file-saver 和文件预览相关模块。
|
||||
|
||||
重型模块保持路由级或组件级动态加载。Rspack 必须把动态 import 转为 Firefox 52 可用的普通 chunk loader。
|
||||
|
||||
## 8. CSS 与视觉一致性方案
|
||||
|
||||
### 8.1 CSS 构建顺序
|
||||
|
||||
CSS 建议按以下顺序处理:
|
||||
|
||||
1. Tailwind 4 生成样式。
|
||||
2. 展平 `@layer`,防止 Firefox 52 忽略整组规则。
|
||||
3. 生成颜色和透明度回退。
|
||||
4. 处理嵌套、逻辑属性、选择器和可安全转换的现代语法。
|
||||
5. Lightning CSS 按 Firefox 52 目标转换。
|
||||
6. 生产模式压缩,开发模式保持未压缩和快速 source map。
|
||||
|
||||
需重点验证当前 `rgb(var(--legacy-*-rgb) / alpha)` 在 Firefox 52 中的实际表现;若浏览器不接受空格分隔和斜杠透明度语法,应在构建期生成逗号分隔的 `rgba()` 或直接颜色值。
|
||||
|
||||
### 8.2 手写兼容 CSS 与文件拆分
|
||||
|
||||
构建工具无法可靠转换,或者转换后仍存在布局和交互差异的样式,直接编写 Firefox 52 专用 CSS。兼容样式不得继续集中追加到 `globals.css`,统一放在独立目录中按职责拆分:
|
||||
|
||||
```text
|
||||
src/styles/firefox52/
|
||||
├── index.css # 只负责按固定顺序汇总核心兼容样式
|
||||
├── tokens.css # 颜色、透明度、阴影和主题变量回退
|
||||
├── base.css # 浏览器基础差异和通用元素修正
|
||||
├── layout.css # 页面框架、侧边栏、滚动区和响应式布局
|
||||
├── overlays.css # Dialog、Popover、Select、Tooltip、Guide 等浮层
|
||||
├── markdown.css # Markdown、代码块、公式、表格和引用
|
||||
├── editors.css # CodeMirror、Tiptap、ProseMirror 等编辑器
|
||||
├── visualizations.css # Mermaid、XYFlow、图表、地图和 Canvas 容器
|
||||
├── chat.css # 普通问答、智能体问答和消息时间线
|
||||
├── writing-research.css # AI 写作、深度研究和公文工作台
|
||||
├── roundtable.css # 会商、流程图和报告协作
|
||||
├── task-workspaces.css # 任务深链、目的树和快捷跳转栏
|
||||
└── admin.css # 管理端表格、表单和配置页
|
||||
```
|
||||
|
||||
拆分规则:
|
||||
|
||||
1. 全局首屏必需的 `tokens/base/layout/overlays` 由 `index.css` 统一引入。
|
||||
2. Markdown、编辑器、可视化、会商、任务工作区和管理端样式随对应路由或组件导入,让 Rspack 生成独立 CSS chunk,避免所有用户加载完整兼容样式。
|
||||
3. 单个兼容 CSS 文件建议控制在 500 行或未压缩 30 KB 以内;超过任一门槛时继续按组件或业务域拆分。
|
||||
4. HTML 根节点由兼容入口设置稳定标记,例如 `data-browser-compat="firefox52"`。兼容规则应挂在该标记下,防止影响现代浏览器和公共组件的默认样式。
|
||||
5. 优先复用语义 token 和现有组件 class,不复制整份第三方组件样式。只覆盖 Firefox 52 实际不兼容的声明。
|
||||
6. `!important` 只用于第三方组件无法通过正常层叠覆盖的场景,并在规则旁注明原样式来源和原因。
|
||||
7. 文件加载顺序必须固定:主题 token → 基础样式 → 布局 → 通用浮层 → 业务页面。禁止依赖偶然的 chunk 到达顺序解决优先级。
|
||||
8. 每个文件明确对应验收页面,删除页面或修复上游兼容性时同步清理失效规则。
|
||||
|
||||
如果某个功能仅需要少量兼容规则,样式文件放在功能模块旁并使用 `.firefox52.css` 后缀;跨页面共用的兼容规则才放入 `src/styles/firefox52/`。构建检查需要统计各文件行数、体积、重复选择器和 `!important` 数量,超过门槛时提示拆分。
|
||||
|
||||
### 8.3 CSS 风险清单
|
||||
|
||||
建立自动扫描和人工验证清单,覆盖:
|
||||
|
||||
- `color-mix()`、OKLCH、现代 `rgb()`/`hsl()`。
|
||||
- `@layer`、CSS nesting、container query。
|
||||
- `:has()`、`:focus-visible`、复杂 `:is()`/`:where()`。
|
||||
- Flexbox `gap`、Grid 差异和 `subgrid`。
|
||||
- `aspect-ratio`、`inset`、`min()`、`max()`、`clamp()`。
|
||||
- `dvh`/`svh`/`lvh`、逻辑方向属性。
|
||||
- `backdrop-filter`、mask、混合模式和复杂滤镜。
|
||||
- sticky、滚动容器、overscroll 和自定义滚动条。
|
||||
- Portal 浮层、z-index、transform containing block 和 `pointer-events`。
|
||||
|
||||
无法可靠转换的布局功能通过兼容 utility 或组件级基础样式回退解决,不能只隐藏出错区域。
|
||||
|
||||
### 8.4 视觉回归范围
|
||||
|
||||
每种主题至少截取以下代表页面:
|
||||
|
||||
1. 登录页和新对话落地页。
|
||||
2. 普通问答流式消息页。
|
||||
3. 智能体列表、创建页和智能体聊天页。
|
||||
4. AI 写作工作台。
|
||||
5. 多智能体会商三步工作台和流程图。
|
||||
6. 知识库列表、编辑器和知识图谱。
|
||||
7. 任务管理、目的树弹窗和快捷跳转栏。
|
||||
8. 管理端表格、Tabs、表单和确认弹窗。
|
||||
9. iframe 嵌入页和公开分享页。
|
||||
10. 站点预览、地图、图表和全屏场景。
|
||||
|
||||
视觉验收重点比较布局边界、间距、颜色、滚动区域、遮罩和弹层位置。截图自动化优先使用与 Firefox 52 匹配的 Selenium/geckodriver;无法稳定自动化的页面纳入人工截图清单。
|
||||
|
||||
## 9. 全功能验收矩阵
|
||||
|
||||
### 9.1 P0:必须首先打通
|
||||
|
||||
| 功能域 | 核心场景 |
|
||||
|---|---|
|
||||
| 登录与鉴权 | 密码登录、URL 参数登录、退出、刷新恢复、鉴权失效 |
|
||||
| 页面框架 | 全量/简洁模式、侧边栏、菜单、主题、路由刷新 |
|
||||
| 普通问答 | 新建会话、历史会话、流式回答、停止、重试、错误显示 |
|
||||
| 消息展示 | Markdown、GFM、代码高亮、公式、链接、引用、流程图 |
|
||||
| 文件 | 上传、附件卡片、产物预览、下载、Word 导出 |
|
||||
| 智能体问答 | 智能体列表、进入聊天、新建和历史会话 |
|
||||
| iframe | `embed=1`、`hideJump=1`、主题、独立鉴权、postMessage |
|
||||
| 错误恢复 | 断网、接口 401/403/500、chunk 加载失败、刷新恢复 |
|
||||
|
||||
### 9.2 P1:主要业务能力
|
||||
|
||||
| 功能域 | 核心场景 |
|
||||
|---|---|
|
||||
| 智能体管理 | 创建、编辑、模型、知识空间、SOUL、技能、保存、引导 |
|
||||
| 舆情分析智能体 | 外部流式接口、会话、错误信息 |
|
||||
| AI 写作 | 经典版、对话版、阶段推进、干预、资料、Markdown 与 Word 下载 |
|
||||
| 深度研究/公文 | 发起任务、过程状态、恢复、结果展示和下载 |
|
||||
| 多智能体会商 | 普通、规划、8BF、岗位会商、链条配置、后台续跑 |
|
||||
| 报告协作 | 会话列表、协作过程、结果编辑和导出 |
|
||||
| 知识空间 | 列表、详情、上传、Markdown 编辑、Wiki、图谱、检索调试 |
|
||||
| Notebook | 列表、详情、资料和生成流程 |
|
||||
| 定时任务 | 任务列表、创建、运行详情、公开运行页 |
|
||||
| 任务深链 | rwfx、3qfx、xdfx、agentfx、单智能体、会商、行动分区 |
|
||||
| 任务报告 | 生成、文件卡片、入库确认、宿主通知 |
|
||||
| 轻应用 | 应用列表、窗口打开、iframe 宿主、动态菜单 |
|
||||
|
||||
### 9.3 P2:管理、配置和扩展能力
|
||||
|
||||
- 用户、活动、记忆、模型、模型交换与并发配置。
|
||||
- 智能体、技能、技能上下线、技能地址扫描/预览/替换/回滚。
|
||||
- 菜单、简洁菜单、按钮、标签、固定问题、推荐问题、提示词前缀。
|
||||
- 敏感词、排行榜、插件、岗位和岗位角色。
|
||||
- 业务链、发布广场、私有广场、技能压缩、LLM 指标和 checkpoint 迁移。
|
||||
- 公开分享、排行榜、会商报告、会商看板和定时任务全屏页面。
|
||||
- Web App/Site Studio、地图、流程图、图表和沙箱 HTML 预览。
|
||||
- 微信样式入口、无界嵌入和其他特殊页面壳。
|
||||
|
||||
### 9.4 单项验收规则
|
||||
|
||||
每个功能项必须记录:
|
||||
|
||||
- 入口 URL、角色和前置数据。
|
||||
- 页面是否正常加载及首个可交互时间。
|
||||
- 主操作、保存/提交、返回和刷新恢复结果。
|
||||
- 是否涉及流式、上传、下载、Worker、Canvas、SVG、WebGL 或 iframe。
|
||||
- Firefox 52 控制台错误和网络失败。
|
||||
- 样式截图、已知差异和回退行为。
|
||||
- 验收人、日期、构建 commit 和结果。
|
||||
|
||||
路由清单应由路由源码自动提取后与人工场景表合并,新增路由若未进入矩阵则 CI 提示失败。
|
||||
|
||||
## 10. 开发阶段
|
||||
|
||||
### 阶段 0:基线和可观测性
|
||||
|
||||
工作项:
|
||||
|
||||
- 固定 Node、pnpm、Firefox 52、Rspack/SWC 候选版本和测试机器信息。
|
||||
- 记录 Vite legacy 的冷构建、热构建、增量构建、内存峰值和产物体积。
|
||||
- 保存当前主要页面在现代浏览器和 Firefox 52 的截图、控制台错误及网络日志。
|
||||
- 自动提取路由清单和动态 import 清单。
|
||||
- 输出依赖语法扫描报告和浏览器 API 使用清单。
|
||||
- 建立可重复执行的 benchmark 脚本,结果保存为 JSON 和 Markdown。
|
||||
|
||||
退出条件:任何人都能按文档复现当前构建时间和 Firefox 52 白屏/错误现状。
|
||||
|
||||
### 阶段 1:Rspack 并行构建骨架
|
||||
|
||||
工作项:
|
||||
|
||||
- 增加 Rspack 配置、HTML、静态资源、alias、环境变量和 public path。
|
||||
- 接入内置 SWC,先处理项目源码,再逐步扩展到依赖 allowlist。
|
||||
- 接入 React、CSS、字体、SVG、图片和动态 import。
|
||||
- 建立 `dev:firefox52`、`build:firefox52:fast` 和 preview 命令。
|
||||
- 暂时保留 Vite legacy 命令作为对照和回退。
|
||||
|
||||
退出条件:Firefox 52 能通过 Rspack dev server 打开登录页和空工作区,没有语法错误与 chunk 加载错误。
|
||||
|
||||
### 阶段 2:运行时与流式链路
|
||||
|
||||
工作项:
|
||||
|
||||
- 重整统一兼容入口和 polyfill 顺序。
|
||||
- 完成 XHR 流式适配器并接入核心请求路径。
|
||||
- 验证取消、超时、错误解析、鉴权头和跨域行为。
|
||||
- 处理 Web Worker、WebSocket、EventSource 和动态 chunk 加载。
|
||||
- 完成普通问答端到端测试。
|
||||
|
||||
退出条件:普通问答可以连续完成发送、流式接收、停止、重试和刷新恢复。
|
||||
|
||||
### 阶段 3:依赖清理和固定补丁
|
||||
|
||||
工作项:
|
||||
|
||||
- 对首批专项依赖分级。
|
||||
- 把现有 Vite 字符串补丁迁移为版本固定、fork、alias 或安装后补丁。
|
||||
- 对 Mermaid、Shiki、Oniguruma、LangSmith、p-queue 等建立单独验收用例。
|
||||
- 配置最终产物危险语法扫描。
|
||||
- 为新增依赖增加 CI 扫描。
|
||||
|
||||
退出条件:生产产物通过静态扫描,补丁均有版本校验,依赖升级导致补丁失效时构建直接失败并给出清晰错误。
|
||||
|
||||
### 阶段 4:CSS 和组件样式兼容
|
||||
|
||||
工作项:
|
||||
|
||||
- 把现有 PostCSS/Tailwind/Lightning CSS 流程接入 Rspack。
|
||||
- 建立 `src/styles/firefox52/`,按主题、布局、浮层和业务域拆分手写兼容 CSS,避免继续扩大 `globals.css`。
|
||||
- 配置核心兼容 CSS 与路由级兼容 CSS 的分包策略,并增加单文件行数、体积和重复规则检查。
|
||||
- 修复主题颜色、透明度、cascade layer、flex gap 和现代选择器问题。
|
||||
- 验证 TDesign、Radix、Tailwind 组件和 Portal 浮层。
|
||||
- 建立 CSS 危险语法扫描与代表页面截图回归。
|
||||
- 验证各主题和三种目标分辨率。
|
||||
|
||||
退出条件:P0 页面和代表组件没有布局塌陷、透明色失效、弹层错位或滚动锁死。
|
||||
|
||||
### 阶段 5:全功能逐批清零
|
||||
|
||||
按以下批次执行,每批都完成“功能、控制台、网络、样式、刷新恢复”五项检查:
|
||||
|
||||
1. 登录、框架、普通问答和文件。
|
||||
2. 智能体管理与智能体问答。
|
||||
3. AI 写作、深度研究、公文。
|
||||
4. 多智能体会商、业务链和报告协作。
|
||||
5. 知识空间、Notebook、Markdown 编辑器和图谱。
|
||||
6. 任务深链、任务报告、轻应用和 iframe。
|
||||
7. 定时任务、管理端和设置页。
|
||||
8. 公开页面、Site Studio、地图、图表和其他扩展能力。
|
||||
|
||||
退出条件:P0、P1 全部通过,P2 完成验证;所有遗留项都有可执行的修复或等价回退方案。
|
||||
|
||||
### 阶段 6:构建性能专项优化
|
||||
|
||||
工作项:
|
||||
|
||||
- 开启并调优 Rspack 持久化缓存和增量构建。
|
||||
- 开发模式关闭压缩,使用低成本 source map。
|
||||
- 生产模式使用 SWC minimizer,CSS 使用 Lightning CSS minimizer。
|
||||
- 缩小 SWC 转换范围,对不兼容依赖使用精确 allowlist。
|
||||
- 对 Mermaid、Shiki、MapLibre、编辑器、会商等重型模块做路由级拆分。
|
||||
- 检查重复依赖、barrel export、无效 locale 和大资源。
|
||||
- 评估 lazy compilation;仅在不破坏 Firefox 52 chunk 加载时启用。
|
||||
- 将耗时阶段输出到构建 profile,逐项消除长任务。
|
||||
|
||||
退出条件:达到第 3.2 节性能门槛,同时功能和样式回归无新增失败。
|
||||
|
||||
### 阶段 7:持续集成和离线部署衔接
|
||||
|
||||
工作项:
|
||||
|
||||
- CI 执行 typecheck、Rspack 构建、危险语法扫描、核心单测和冒烟测试。
|
||||
- 保存构建时间趋势,超过阈值时提示回归。
|
||||
- 固定包版本和校验值,保证离线环境可重复安装。
|
||||
- 离线依赖与发布资产交由 `knowledge-base-bj/deerflow-offline-deployment` 仓库维护,本业务仓库只保留源码和依赖清单。
|
||||
- 更新启动脚本、部署文档、缓存策略和静态资源响应头。
|
||||
|
||||
退出条件:干净环境可以离线安装、构建、启动并通过 Firefox 52 冒烟测试。
|
||||
|
||||
### 阶段 8:切换与旧链路清理
|
||||
|
||||
工作项:
|
||||
|
||||
- 完成最终功能矩阵、视觉报告和性能报告评审。
|
||||
- 将 Firefox 52 开发和构建命令切换到 Rspack。
|
||||
- 删除已经被固定补丁替代的 Vite legacy 临时代码。
|
||||
- 保留一段时间的 Vite 基准脚本,确认稳定后再彻底清理兼容分支中的旧依赖。
|
||||
|
||||
退出条件:连续多个提交未出现功能、样式和性能回归,Rspack 成为兼容分支唯一正式构建链。
|
||||
|
||||
## 11. 性能测试方法
|
||||
|
||||
每组测试至少执行 5 次,取中位数并记录 P95:
|
||||
|
||||
1. **冷启动**:删除构建缓存后启动开发服务器,测量到首页可请求时间。
|
||||
2. **热启动**:保留缓存重启开发服务器。
|
||||
3. **TSX 增量**:修改一个叶子组件和一个高层布局组件。
|
||||
4. **CSS 增量**:修改组件样式和全局主题样式。
|
||||
5. **冷生产构建**:删除 Rspack 缓存和 dist。
|
||||
6. **热生产构建**:保留缓存、源码不变再次构建。
|
||||
7. **单文件生产变更**:修改一个组件后重新构建。
|
||||
|
||||
记录字段:commit、构建模式、CPU、内存、磁盘、Node/pnpm 版本、总耗时、各阶段耗时、峰值内存、模块数量、chunk 数量、JS/CSS 体积和缓存命中率。
|
||||
|
||||
性能比较必须在相同机器和相同后台负载下进行。正式报告同时保留原始日志,避免只记录一次最佳结果。
|
||||
|
||||
## 12. 主分支同步策略
|
||||
|
||||
兼容分支需要持续接收正常业务开发,采用以下约束:
|
||||
|
||||
- 构建和兼容代码集中在独立目录与入口,减少对业务组件的侵入。
|
||||
- 能通过适配器解决的问题不直接修改业务协议。
|
||||
- 定期从 `master-zg` 合并新功能,每次合并后自动更新路由和依赖扫描结果。
|
||||
- 新增路由必须进入功能矩阵;新增依赖必须进入依赖兼容清单。
|
||||
- 新增流式接口必须使用统一传输层,不能绕过 Firefox 52 XHR 回退。
|
||||
- 新增主题和全局组件必须进入样式截图集。
|
||||
- 冲突解决优先保留主分支业务逻辑,再在兼容层补充旧浏览器处理。
|
||||
|
||||
## 13. 风险与处理原则
|
||||
|
||||
| 风险 | 处理原则 |
|
||||
|---|---|
|
||||
| 第三方依赖停止支持旧浏览器 | 固定版本或维护兼容 fork |
|
||||
| fetch 流式响应不可用 | XHR onprogress 统一回退 |
|
||||
| Map/WebGL 能力差异 | 降级依赖或提供保持业务信息的二维/静态回退 |
|
||||
| 编辑器、语法高亮和 Worker 依赖现代能力 | classic worker、主线程回退或兼容版本 |
|
||||
| CSS 转换后仍存在布局差异 | 兼容 utility、组件基础样式和截图回归 |
|
||||
| Polyfill 体积过大 | 单目标按需注入,重型功能路由级加载 |
|
||||
| 兼容补丁随依赖升级失效 | 精确版本校验,失败时终止构建 |
|
||||
| Rspack 插件兼容不足 | 优先使用内置能力,必要时编写小型 loader/plugin |
|
||||
| 主分支持续新增功能 | 自动路由、依赖、API 和 CSS 扫描 |
|
||||
| 旧浏览器自动化不稳定 | 自动冒烟与人工矩阵结合,保留截图和日志证据 |
|
||||
|
||||
## 14. 交付物
|
||||
|
||||
实施完成后应包含:
|
||||
|
||||
- Rspack 开发、快速构建和生产构建配置。
|
||||
- Firefox 52 统一 polyfill 入口和流式传输适配器。
|
||||
- 第三方依赖兼容清单及固定补丁。
|
||||
- CSS 兼容流水线、扫描脚本和视觉基准图。
|
||||
- 按职责拆分的 Firefox 52 手写兼容 CSS,以及对应的体积和重复规则检查。
|
||||
- 路由级功能验收矩阵。
|
||||
- 自动冒烟、危险语法扫描和构建性能脚本。
|
||||
- 基线报告、最终性能对比报告和已知差异说明。
|
||||
- 开发运行、离线构建、部署和故障排查文档。
|
||||
|
||||
## 15. 最终验收清单
|
||||
|
||||
- [x] Firefox 52 开发服务器可直接打开并调试。
|
||||
- [x] 登录、导航、主题、路由刷新无白屏。
|
||||
- [x] 普通问答和智能体问答可以稳定流式输出。
|
||||
- [ ] 上传、下载、Markdown、公式、代码、流程图和 Word 导出通过。
|
||||
- [ ] AI 写作、研究、会商、知识库、任务深链等 P1 功能全部通过。
|
||||
- [ ] 管理端、设置、公开页和扩展能力完成 P2 验证。
|
||||
- [ ] 所有现有主题和目标分辨率完成视觉回归。
|
||||
- [x] 产物通过危险语法和资源路径扫描。
|
||||
- [ ] 冷构建、热构建和增量构建达到性能门槛(冷/热生产构建已记录,增量构建仍需单独量化)。
|
||||
- [ ] 连续构建与长时间运行无 chunk 丢失和内存异常。
|
||||
- [ ] 新增功能与依赖具备持续兼容检查。
|
||||
- [ ] 离线环境可以重复安装、构建和部署。
|
||||
@@ -93,8 +93,7 @@ frontend-web/
|
||||
|
||||
| 脚本 | 命令 | 说明 |
|
||||
|---|---|---|
|
||||
| `pnpm dev` / `npm run dev` | `rspack serve --config rspack.firefox52.config.mjs --mode development` | 兼容分支默认启动 Firefox 52 开发服务器,端口 5174 |
|
||||
| `pnpm dev:vite` / `npm run dev:vite` | `vite` | 仅在现代浏览器调试时启动原 Vite 开发服务器 |
|
||||
| `pnpm dev` | `vite` | 启动开发服务器(默认端口 5173;项目实际通过仓库根脚本统一为 5174) |
|
||||
| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) |
|
||||
| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 |
|
||||
| `pnpm typecheck` | `tsc --noEmit` | 仅做 TS 类型检查,不输出文件 |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 麒麟系统 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 基线(迁移零新增)
|
||||
```
|
||||
+5
-39
@@ -12,46 +12,12 @@
|
||||
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>
|
||||
|
||||
Generated
+554
-5062
File diff suppressed because it is too large.
Load diff
+10
-49
@@ -4,40 +4,20 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "rspack serve --config rspack.firefox52.config.mjs --mode development",
|
||||
"dev:vite": "vite",
|
||||
"dev:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode development",
|
||||
"dev": "vite",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
||||
"build:firefox52": "rspack build --config rspack.firefox52.config.mjs --mode production",
|
||||
"build:firefox52:fast": "rspack build --config rspack.firefox52.config.mjs --mode production --env fast=true",
|
||||
"build:firefox52:vite": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode firefox52 --minify false",
|
||||
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
|
||||
"build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test",
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"preview:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode production --env preview=true",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
||||
"test:answer-feedback": "node --experimental-strip-types --test src/components/workspace/agents/agent-feedback-visibility.test.ts src/components/workspace/messages/answer-feedback-eligibility.test.ts src/strategy-components/api/answer-feedback-batching.test.ts",
|
||||
"test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts",
|
||||
"test:abort-signal": "node --experimental-strip-types --test src/core/compat/abort-signal.test.ts",
|
||||
"test:firefox52-fetch": "node --experimental-strip-types --test src/core/compat/firefox52-fetch.test.ts",
|
||||
"test:firefox52-markdown": "node --experimental-strip-types --test src/core/rehype/index.test.ts",
|
||||
"smoke:firefox52": "node scripts/smoke-firefox52.mjs",
|
||||
"smoke:firefox52:live": "node scripts/smoke-firefox52.mjs --live",
|
||||
"smoke:firefox52:agent-live": "node scripts/smoke-firefox52-agent-live.mjs",
|
||||
"smoke:firefox52:full": "node scripts/smoke-firefox52-full.mjs",
|
||||
"smoke:firefox52:themes": "node scripts/smoke-firefox52-themes.mjs",
|
||||
"benchmark:firefox52": "node scripts/benchmark-firefox52.mjs",
|
||||
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
|
||||
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:firefox52-css": "node scripts/check-firefox52-css.mjs",
|
||||
"check:firefox52-js": "node scripts/check-firefox52-js.mjs",
|
||||
"test:firefox52-build": "node --test build/firefox52/legacy-generator-loader.test.cjs build/firefox52/source-compat-loader.test.cjs",
|
||||
"check:firefox52": "pnpm test:firefox52-build && pnpm test:firefox52-markdown && pnpm check:firefox52-css && pnpm check:firefox52-js"
|
||||
"check:legacy-css": "node scripts/check-legacy-css.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -91,35 +71,32 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-js": "3.50.0",
|
||||
"core-js": "3.45.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fflate": "^0.8.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "4.7.1",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"marked": "16.4.2",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"ogl": "^1.0.11",
|
||||
"pepjs": "0.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -137,42 +114,26 @@
|
||||
"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",
|
||||
"web-streams-polyfill": "4.3.0"
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2",
|
||||
"browserslist": [
|
||||
"Firefox >= 52"
|
||||
]
|
||||
"Chrome >= 80"
|
||||
],
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -2,6 +2,11 @@ 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],
|
||||
};
|
||||
@@ -1,335 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
CopyRspackPlugin,
|
||||
DefinePlugin,
|
||||
HtmlRspackPlugin,
|
||||
LightningCssMinimizerRspackPlugin,
|
||||
SwcJsMinimizerRspackPlugin,
|
||||
} from "@rspack/core";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const firefox52Target = { firefox: "52" };
|
||||
|
||||
function loadViteEnvironment(mode) {
|
||||
const values = {};
|
||||
const files = [
|
||||
".env",
|
||||
`.env.${mode}`,
|
||||
".env.local",
|
||||
`.env.${mode}.local`,
|
||||
];
|
||||
|
||||
for (const filename of files) {
|
||||
const fullPath = path.join(rootDir, filename);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
Object.assign(values, dotenv.parse(fs.readFileSync(fullPath)));
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("VITE_") && typeof value === "string") {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(values).filter(([key]) => key.startsWith("VITE_")),
|
||||
),
|
||||
BASE_URL: process.env.VITE_ASSET_BASE ?? "./",
|
||||
MODE: mode,
|
||||
DEV: mode === "development",
|
||||
PROD: mode === "production",
|
||||
SSR: false,
|
||||
};
|
||||
}
|
||||
|
||||
function cssLoaders(postcssConfig) {
|
||||
const loaders = [
|
||||
{
|
||||
loader: "builtin:lightningcss-loader",
|
||||
options: {
|
||||
targets: firefox52Target,
|
||||
include: {
|
||||
logicalProperties: true,
|
||||
},
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (postcssConfig) {
|
||||
loaders.push({
|
||||
loader: "postcss-loader",
|
||||
options: {
|
||||
implementation: "postcss",
|
||||
postcssOptions: {
|
||||
config: path.join(rootDir, "build", "firefox52", postcssConfig),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return loaders;
|
||||
}
|
||||
|
||||
function swcLoader(isDevelopment) {
|
||||
return {
|
||||
loader: "builtin:swc-loader",
|
||||
options: {
|
||||
detectSyntax: "auto",
|
||||
isModule: "unknown",
|
||||
env: {
|
||||
targets: firefox52Target,
|
||||
bugfixes: true,
|
||||
shippedProposals: true,
|
||||
},
|
||||
jsc: {
|
||||
externalHelpers: false,
|
||||
transform: {
|
||||
react: {
|
||||
runtime: "automatic",
|
||||
development: isDevelopment,
|
||||
refresh: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default (_env = {}, argv = {}) => {
|
||||
const mode = argv.mode === "development" ? "development" : "production";
|
||||
const isDevelopment = mode === "development";
|
||||
const isFastBuild = _env.fast === true || _env.fast === "true";
|
||||
const isPreview = _env.preview === true || _env.preview === "true";
|
||||
const assetBase = isDevelopment || isPreview
|
||||
? "/"
|
||||
: process.env.VITE_ASSET_BASE ?? "./";
|
||||
const buildEnv = loadViteEnvironment(mode);
|
||||
|
||||
return {
|
||||
name: "firefox52",
|
||||
mode,
|
||||
context: rootDir,
|
||||
target: ["web", "es5"],
|
||||
entry: {
|
||||
app: path.join(rootDir, "src", "main.tsx"),
|
||||
},
|
||||
output: {
|
||||
path: path.join(rootDir, "dist-firefox52"),
|
||||
publicPath: assetBase,
|
||||
filename: isDevelopment ? "assets/[name].js" : "assets/[name].[contenthash:8].js",
|
||||
chunkFilename: isDevelopment
|
||||
? "assets/[name].chunk.js"
|
||||
: "assets/[name].[contenthash:8].chunk.js",
|
||||
cssFilename: isDevelopment
|
||||
? "assets/[name].css"
|
||||
: "assets/[name].[contenthash:8].css",
|
||||
cssChunkFilename: isDevelopment
|
||||
? "assets/[name].chunk.css"
|
||||
: "assets/[name].[contenthash:8].chunk.css",
|
||||
clean: true,
|
||||
module: false,
|
||||
environment: {
|
||||
arrowFunction: false,
|
||||
asyncFunction: false,
|
||||
bigIntLiteral: false,
|
||||
const: false,
|
||||
destructuring: false,
|
||||
dynamicImport: false,
|
||||
module: false,
|
||||
optionalChaining: false,
|
||||
templateLiteral: false,
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
type: "persistent",
|
||||
name: `firefox52-${mode}`,
|
||||
version: "2-tailwind3",
|
||||
buildDependencies: [
|
||||
fileURLToPath(import.meta.url),
|
||||
path.join(rootDir, "package.json"),
|
||||
path.join(rootDir, "postcss.config.js"),
|
||||
path.join(rootDir, "tailwind.config.ts"),
|
||||
path.join(rootDir, "scripts", "postcss-dark-descendant.mjs"),
|
||||
path.join(rootDir, "scripts", "postcss-firefox52-rgb-vars.mjs"),
|
||||
path.join(rootDir, "scripts", "postcss-firefox52-where.mjs"),
|
||||
path.join(rootDir, "build", "firefox52", "source-compat-loader.cjs"),
|
||||
path.join(rootDir, "build", "firefox52", "legacy-generator-loader.cjs"),
|
||||
path.join(rootDir, "build", "firefox52", "postcss-tailwind.config.mjs"),
|
||||
path.join(rootDir, "build", "firefox52", "postcss-compat.config.mjs"),
|
||||
],
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.join(rootDir, ".rspack-cache"),
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
css: true,
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
devtool: isDevelopment ? "cheap-module-source-map" : false,
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
enforce: "pre",
|
||||
test(resourcePath) {
|
||||
const id = resourcePath.replaceAll("\\", "/");
|
||||
return (
|
||||
/\/p-queue\/dist\/index\.js$/.test(id) ||
|
||||
/\/mdast-util-gfm-autolink-literal\/lib\/index\.js$/.test(id) ||
|
||||
id.includes("/oniguruma-parser/") ||
|
||||
id.includes("/node_modules/mermaid/dist/chunks/") ||
|
||||
id.includes("/node_modules/web-streams-polyfill/dist/") ||
|
||||
id.includes("/node_modules/@xyflow/react/") ||
|
||||
id.includes("/node_modules/react-router/") ||
|
||||
id.includes("/node_modules/zod/") ||
|
||||
id.includes("/node_modules/langsmith/")
|
||||
);
|
||||
},
|
||||
use: path.join(rootDir, "build", "firefox52", "source-compat-loader.cjs"),
|
||||
},
|
||||
{
|
||||
oneOf: [
|
||||
{
|
||||
resourceQuery: /raw/,
|
||||
type: "asset/source",
|
||||
},
|
||||
{
|
||||
test(resourcePath) {
|
||||
const id = resourcePath.replaceAll("\\", "/");
|
||||
return (
|
||||
id.includes("/node_modules/@langchain/core/") ||
|
||||
id.includes("/node_modules/@langchain/langgraph-sdk/") ||
|
||||
id.includes("/node_modules/langsmith/")
|
||||
);
|
||||
},
|
||||
// Loaders run right-to-left: normalize modules that declare or
|
||||
// consume async iterators first, then let the fast built-in SWC
|
||||
// pass handle the complete package consistently.
|
||||
use: [
|
||||
swcLoader(isDevelopment),
|
||||
path.join(rootDir, "build", "firefox52", "legacy-generator-loader.cjs"),
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(?:js|mjs|cjs|jsx|ts|tsx)$/,
|
||||
exclude: [
|
||||
/node_modules[\\/]core-js[\\/]/,
|
||||
/node_modules[\\/]regenerator-runtime[\\/]/,
|
||||
/node_modules[\\/]web-streams-polyfill[\\/]/,
|
||||
],
|
||||
use: swcLoader(isDevelopment),
|
||||
},
|
||||
{
|
||||
test: /[\\/]src[\\/]styles[\\/]globals\.css$/,
|
||||
type: "css",
|
||||
use: cssLoaders("postcss-tailwind.config.mjs"),
|
||||
},
|
||||
{
|
||||
test: /[\\/]src[\\/].*\.css$/,
|
||||
type: "css",
|
||||
use: cssLoaders("postcss-compat.config.mjs"),
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
type: "css",
|
||||
use: cssLoaders(null),
|
||||
},
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: "webassembly/async",
|
||||
},
|
||||
{
|
||||
test: /\.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot)$/i,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[name].[contenthash:8][ext]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".json"],
|
||||
alias: {
|
||||
"highlight.js/lib/languages/sql_more$": "highlight.js/lib/languages/sql",
|
||||
"@/env$": path.join(rootDir, "src", "env.ts"),
|
||||
"next/navigation$": path.join(rootDir, "src", "shims", "next-navigation.ts"),
|
||||
"next/link$": path.join(rootDir, "src", "shims", "next-link.tsx"),
|
||||
"@vite": path.join(rootDir, "src"),
|
||||
"@": path.join(rootDir, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
new DefinePlugin({
|
||||
"import.meta.env": JSON.stringify(buildEnv),
|
||||
__FIREFOX52_BUILD__: "true",
|
||||
}),
|
||||
new HtmlRspackPlugin({
|
||||
template: path.join(rootDir, "build", "firefox52", "index.html"),
|
||||
filename: "index.html",
|
||||
inject: "body",
|
||||
scriptLoading: "blocking",
|
||||
minify: false,
|
||||
}),
|
||||
new CopyRspackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.join(rootDir, "public"),
|
||||
to: ".",
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
minimize: !isDevelopment && !isFastBuild,
|
||||
minimizer: [
|
||||
new SwcJsMinimizerRspackPlugin({
|
||||
minimizerOptions: {
|
||||
ecma: 5,
|
||||
},
|
||||
}),
|
||||
new LightningCssMinimizerRspackPlugin({
|
||||
minimizerOptions: {
|
||||
targets: firefox52Target,
|
||||
include: {
|
||||
logicalProperties: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
devServer: {
|
||||
host: "0.0.0.0",
|
||||
port: Number(process.env.FIREFOX52_PORT || 5174),
|
||||
hot: false,
|
||||
liveReload: true,
|
||||
historyApiFallback: true,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
static: {
|
||||
directory: isPreview
|
||||
? path.join(rootDir, "dist-firefox52")
|
||||
: path.join(rootDir, "public"),
|
||||
watch: !isPreview,
|
||||
},
|
||||
devMiddleware: {
|
||||
publicPath: "/",
|
||||
writeToDisk: isPreview,
|
||||
},
|
||||
client: {
|
||||
overlay: true,
|
||||
},
|
||||
},
|
||||
stats: "errors-warnings",
|
||||
performance: false,
|
||||
};
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const rounds = Math.max(1, Number(process.env.FIREFOX_BENCHMARK_ROUNDS || 5));
|
||||
const cold = process.argv.includes("--cold");
|
||||
const fast = process.argv.includes("--fast");
|
||||
const command = fast ? "build:firefox52:fast" : "build:firefox52";
|
||||
const reportDir = path.join(rootDir, ".runtime", "firefox52-benchmark");
|
||||
|
||||
function removeWorkspaceDirectory(relativePath) {
|
||||
const target = path.resolve(rootDir, relativePath);
|
||||
if (path.dirname(target) !== rootDir) throw new Error(`拒绝清理工作区之外的目录:${target}`);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runBuild() {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = performance.now();
|
||||
const executable = process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : "pnpm";
|
||||
const args = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${command}`] : [command];
|
||||
const child = spawn(executable, args, {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
resolve({
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
exitCode: code,
|
||||
signal,
|
||||
});
|
||||
});
|
||||
child.on("error", (error) => resolve({ durationMs: Math.round(performance.now() - startedAt), exitCode: -1, error: String(error) }));
|
||||
});
|
||||
}
|
||||
|
||||
function percentile(values, ratio) {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.ceil(ratio * sorted.length) - 1];
|
||||
}
|
||||
|
||||
function artifactStats() {
|
||||
const assetsDir = path.join(rootDir, "dist-firefox52", "assets");
|
||||
const stats = { jsFiles: 0, jsBytes: 0, cssFiles: 0, cssBytes: 0 };
|
||||
if (!fs.existsSync(assetsDir)) return stats;
|
||||
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
const size = fs.statSync(path.join(assetsDir, entry.name)).size;
|
||||
if (/\.js$/i.test(entry.name)) {
|
||||
stats.jsFiles += 1;
|
||||
stats.jsBytes += size;
|
||||
} else if (/\.css$/i.test(entry.name)) {
|
||||
stats.cssFiles += 1;
|
||||
stats.cssBytes += size;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
if (cold) {
|
||||
const productionCache = path.resolve(rootDir, ".rspack-cache", "firefox52-production");
|
||||
if (!productionCache.startsWith(`${path.resolve(rootDir, ".rspack-cache")}${path.sep}`)) {
|
||||
throw new Error(`拒绝清理预期缓存目录之外的路径:${productionCache}`);
|
||||
}
|
||||
fs.rmSync(productionCache, { recursive: true, force: true });
|
||||
removeWorkspaceDirectory("dist-firefox52");
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
for (let index = 0; index < rounds; index += 1) {
|
||||
console.log(`\n[Firefox 52 benchmark] ${index + 1}/${rounds} ${command}`);
|
||||
const sample = await runBuild();
|
||||
samples.push(sample);
|
||||
if (sample.exitCode !== 0) break;
|
||||
}
|
||||
|
||||
const successfulDurations = samples.filter((item) => item.exitCode === 0).map((item) => item.durationMs);
|
||||
const steadyDurations = successfulDurations.slice(1);
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
||||
const report = {
|
||||
generatedAt: now.toISOString(),
|
||||
command: `pnpm ${command}`,
|
||||
coldCacheBeforeFirstRun: cold,
|
||||
requestedRounds: rounds,
|
||||
environment: {
|
||||
platform: `${os.platform()} ${os.release()} ${os.arch()}`,
|
||||
cpu: os.cpus()[0]?.model || "unknown",
|
||||
logicalCpus: os.cpus().length,
|
||||
totalMemoryBytes: os.totalmem(),
|
||||
node: process.version,
|
||||
pnpm: process.env.npm_config_user_agent || "run via pnpm.cmd",
|
||||
},
|
||||
samples,
|
||||
summary: {
|
||||
successfulRuns: successfulDurations.length,
|
||||
medianMs: percentile(successfulDurations, 0.5),
|
||||
p95Ms: percentile(successfulDurations, 0.95),
|
||||
minMs: successfulDurations.length ? Math.min(...successfulDurations) : null,
|
||||
maxMs: successfulDurations.length ? Math.max(...successfulDurations) : null,
|
||||
steadyStateMedianMs: percentile(steadyDurations, 0.5),
|
||||
steadyStateP95Ms: percentile(steadyDurations, 0.95),
|
||||
},
|
||||
artifacts: artifactStats(),
|
||||
};
|
||||
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const jsonPath = path.join(reportDir, `${stamp}.json`);
|
||||
const markdownPath = path.join(reportDir, `${stamp}.md`);
|
||||
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
fs.writeFileSync(markdownPath, `# Firefox 52 Rspack 构建基准\n\n- 时间:${report.generatedAt}\n- 命令:\`${report.command}\`\n- 轮次:${samples.length}\n- 全部中位数:${report.summary.medianMs ?? "-"} ms\n- 全部 P95:${report.summary.p95Ms ?? "-"} ms\n- 首轮后的稳态中位数:${report.summary.steadyStateMedianMs ?? "-"} ms\n- 首轮后的稳态 P95:${report.summary.steadyStateP95Ms ?? "-"} ms\n- JS:${report.artifacts.jsFiles} 个 / ${report.artifacts.jsBytes} bytes\n- CSS:${report.artifacts.cssFiles} 个 / ${report.artifacts.cssBytes} bytes\n\n| 轮次 | 耗时(ms) | 退出码 |\n|---:|---:|---:|\n${samples.map((sample, index) => `| ${index + 1} | ${sample.durationMs} | ${sample.exitCode} |`).join("\n")}\n`);
|
||||
|
||||
console.log(`\nFirefox 52 benchmark median=${report.summary.medianMs}ms p95=${report.summary.p95Ms}ms steadyMedian=${report.summary.steadyStateMedianMs}ms`);
|
||||
console.log(`JSON: ${jsonPath}`);
|
||||
console.log(`Markdown: ${markdownPath}`);
|
||||
if (successfulDurations.length !== rounds) process.exitCode = 1;
|
||||
@@ -1,181 +0,0 @@
|
||||
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,48 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { parse } from "acorn";
|
||||
|
||||
const assetsDir = path.resolve("dist-firefox52/assets");
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error("dist-firefox52/assets 不存在,请先运行 pnpm build:firefox52");
|
||||
}
|
||||
|
||||
const jsFiles = fs.readdirSync(assetsDir).filter((file) => file.endsWith(".js"));
|
||||
if (jsFiles.length === 0) {
|
||||
throw new Error("dist-firefox52/assets 中没有 JavaScript 构建产物");
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
let totalBytes = 0;
|
||||
for (const file of jsFiles) {
|
||||
const source = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
||||
totalBytes += Buffer.byteLength(source);
|
||||
try {
|
||||
// Firefox 52 implements ES2017 parsing, but not later syntax such as
|
||||
// optional chaining, BigInt literals, object rest, or dynamic import.
|
||||
parse(source, {
|
||||
ecmaVersion: 2017,
|
||||
sourceType: "script",
|
||||
allowHashBang: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const location = error.loc ? `${error.loc.line}:${error.loc.column}` : "unknown";
|
||||
failures.push(`${file}:${location} ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Firefox 52 JavaScript 语法检查失败:\n");
|
||||
console.error(failures.slice(0, 50).join("\n"));
|
||||
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Firefox 52 JavaScript 语法检查通过(${jsFiles.length} 个文件,${(
|
||||
totalBytes /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(1)} MiB)。`,
|
||||
);
|
||||
+9
-35
@@ -1,47 +1,35 @@
|
||||
import fs from "node:fs";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postcss from "postcss";
|
||||
|
||||
/**
|
||||
* Firefox 52 CSS 产物守卫:构建后运行,防止回归。
|
||||
* 旧浏览器(麒麟 Chromium ~80)CSS 产物守卫:构建后运行,防止回归。
|
||||
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
|
||||
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
|
||||
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式;
|
||||
* - :where() 由 Firefox 专用 PostCSS 插件展开;
|
||||
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式,全库 :is( 为 0;
|
||||
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
|
||||
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
|
||||
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
|
||||
* :has() 按渐进增强放行,关键输入框布局由显式 class 和 :focus-within 兜底。
|
||||
* :has()(Chrome 105+)与 :where()(Chrome 88+)按渐进增强放行,不在检查范围。
|
||||
*/
|
||||
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
|
||||
const assetsDir = path.resolve("dist-firefox52/assets");
|
||||
const assetsDir = path.resolve("dist/assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
throw new Error("dist-firefox52/assets 不存在,请先运行 pnpm build:firefox52:fast");
|
||||
throw new Error("dist/assets 不存在,请先运行 pnpm build");
|
||||
}
|
||||
|
||||
const cssFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".css"));
|
||||
if (cssFiles.length === 0) throw new Error("dist-firefox52/assets 中没有 CSS 构建产物");
|
||||
if (cssFiles.length === 0) throw new Error("dist/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");
|
||||
@@ -49,16 +37,8 @@ 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;
|
||||
@@ -80,19 +60,13 @@ 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("Firefox 52 CSS 兼容检查失败:\n");
|
||||
console.error("旧浏览器 CSS 兼容检查失败:\n");
|
||||
console.error(failures.slice(0, 50).join("\n"));
|
||||
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Firefox 52 CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明,${progressiveHas} 处渐进式 :has())。`,
|
||||
`旧浏览器(麒麟 Chromium ~80)CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明)。`,
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
|
||||
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
|
||||
*
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
const LEGACY_RGB_NAME = /^--legacy-[a-z0-9-]+-rgb$/i;
|
||||
const RGB_NAME = /^--[a-z0-9-]+-rgb$/i;
|
||||
const RGB_CHANNELS = /^\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*$/;
|
||||
const RGB_CHANNEL_REFERENCE = /^\s*var\(\s*(--[a-z0-9-]+-rgb)(?:\s*,\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?))?\s*\)\s*$/i;
|
||||
const INHERENT_ALPHA_TOKENS = new Set([
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
"sidebar-border",
|
||||
]);
|
||||
const ALPHA_MULTIPLIERS = [0, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 1];
|
||||
|
||||
function alphaVariableSuffix(multiplier) {
|
||||
return String(Math.round(multiplier * 1000));
|
||||
}
|
||||
|
||||
function formatAlpha(value) {
|
||||
return String(Math.round(value * 1_000_000) / 1_000_000);
|
||||
}
|
||||
|
||||
function legacyAlphaToken(variableName) {
|
||||
const match = variableName.match(/^--legacy-(.+)-rgb$/);
|
||||
return match && INHERENT_ALPHA_TOKENS.has(match[1]) ? match[1] : null;
|
||||
}
|
||||
|
||||
function normalizeAlpha(variableName, alpha) {
|
||||
const percentage = alpha.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (percentage) return String(Number(percentage[1]) / 100);
|
||||
|
||||
const token = legacyAlphaToken(variableName);
|
||||
if (!token) return alpha;
|
||||
const composite = alpha.match(
|
||||
new RegExp(
|
||||
`^calc\\(var\\(--${token}-alpha,\\s*1\\)\\s*\\*\\s*(.+)\\)$`,
|
||||
),
|
||||
);
|
||||
if (!composite) return alpha;
|
||||
|
||||
const multiplier = composite[1].trim();
|
||||
const numeric = Number(multiplier);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return `var(--ff52-${token}-alpha-${alphaVariableSuffix(numeric)})`;
|
||||
}
|
||||
if (/^var\(--tw-[a-z-]+-opacity,\s*1\)$/.test(multiplier)) {
|
||||
return `var(--ff52-${token}-alpha-${alphaVariableSuffix(1)})`;
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function findClosingParenthesis(value, openingIndex) {
|
||||
let depth = 0;
|
||||
for (let index = openingIndex; index < value.length; index += 1) {
|
||||
if (value[index] === "(") depth += 1;
|
||||
if (value[index] !== ")") continue;
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function commaChannelValue(value) {
|
||||
const channels = value.match(RGB_CHANNELS);
|
||||
if (channels) return `${channels[1]}, ${channels[2]}, ${channels[3]}`;
|
||||
|
||||
const reference = value.match(RGB_CHANNEL_REFERENCE);
|
||||
if (!reference) return null;
|
||||
const fallback = reference[2]
|
||||
? `, ${reference[2]}, ${reference[3]}, ${reference[4]}`
|
||||
: "";
|
||||
return `var(${reference[1]}-comma${fallback})`;
|
||||
}
|
||||
|
||||
function rewriteLegacyRgbFunctions(
|
||||
value,
|
||||
convertibleNames = { has: (name) => LEGACY_RGB_NAME.test(name) },
|
||||
) {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf("rgb(var(--", cursor);
|
||||
if (start < 0) return output + value.slice(cursor);
|
||||
output += value.slice(cursor, start);
|
||||
|
||||
const varStart = start + 4;
|
||||
const varEnd = findClosingParenthesis(value, varStart + 3);
|
||||
const rgbEnd = findClosingParenthesis(value, start + 3);
|
||||
if (varEnd < 0 || rgbEnd < 0 || varEnd >= rgbEnd) {
|
||||
output += value.slice(start, start + 4);
|
||||
cursor = start + 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
const variableName = value.slice(varStart + 4, varEnd).trim();
|
||||
const separator = value.slice(varEnd + 1, rgbEnd);
|
||||
const slash = separator.indexOf("/");
|
||||
if (!RGB_NAME.test(variableName) || !convertibleNames.has(variableName) || slash < 0) {
|
||||
output += value.slice(start, rgbEnd + 1);
|
||||
cursor = rgbEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const alpha = normalizeAlpha(
|
||||
variableName,
|
||||
separator.slice(slash + 1).trim(),
|
||||
);
|
||||
output += `rgba(var(${variableName}-comma), ${alpha})`;
|
||||
cursor = rgbEnd + 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Firefox 52 cannot parse CSS Color 4's `rgb(var(--channels) / alpha)` form.
|
||||
* Keep the space-separated live channels for modern builds, add an equivalent
|
||||
* comma-separated variable, and rewrite only the Firefox artifact to rgba().
|
||||
*/
|
||||
export default function firefox52RgbVars() {
|
||||
const convertibleNames = new Set();
|
||||
return {
|
||||
postcssPlugin: "postcss-firefox52-rgb-vars",
|
||||
Once(root) {
|
||||
root.walkDecls((declaration) => {
|
||||
if (RGB_NAME.test(declaration.prop)) {
|
||||
const commaValue = commaChannelValue(declaration.value);
|
||||
if (commaValue) {
|
||||
convertibleNames.add(declaration.prop);
|
||||
declaration.cloneAfter({
|
||||
prop: `${declaration.prop}-comma`,
|
||||
value: commaValue,
|
||||
});
|
||||
|
||||
const token = legacyAlphaToken(declaration.prop);
|
||||
if (token) {
|
||||
const alphaDeclaration = declaration.parent?.nodes?.find(
|
||||
(node) =>
|
||||
node.type === "decl" && node.prop === `--${token}-alpha`,
|
||||
);
|
||||
const inherentAlpha = Number(alphaDeclaration?.value ?? 1);
|
||||
for (const multiplier of ALPHA_MULTIPLIERS) {
|
||||
declaration.cloneAfter({
|
||||
prop: `--ff52-${token}-alpha-${alphaVariableSuffix(multiplier)}`,
|
||||
value: formatAlpha(inherentAlpha * multiplier),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
Declaration(declaration) {
|
||||
if (!declaration.value.includes("rgb(var(--")) return;
|
||||
declaration.value = rewriteLegacyRgbFunctions(
|
||||
declaration.value,
|
||||
convertibleNames,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
firefox52RgbVars.postcss = true;
|
||||
|
||||
export { rewriteLegacyRgbFunctions };
|
||||
@@ -1,47 +0,0 @@
|
||||
import selectorParser from "postcss-selector-parser";
|
||||
|
||||
function firstWhere(selector) {
|
||||
let found;
|
||||
selector.walkPseudos((pseudo) => {
|
||||
if (!found && pseudo.value.toLowerCase() === ":where") found = pseudo;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function expandWhere(selector) {
|
||||
const pseudo = firstWhere(selector);
|
||||
if (!pseudo) return [selector];
|
||||
const alternatives = pseudo.nodes ?? [];
|
||||
if (alternatives.length === 0) {
|
||||
pseudo.remove();
|
||||
return expandWhere(selector);
|
||||
}
|
||||
|
||||
return alternatives.flatMap((alternative) => {
|
||||
const clone = selector.clone();
|
||||
const clonePseudo = firstWhere(clone);
|
||||
if (!clonePseudo) return [clone];
|
||||
clonePseudo.replaceWith(...alternative.nodes.map((node) => node.clone()));
|
||||
return expandWhere(clone);
|
||||
});
|
||||
}
|
||||
|
||||
/** Expand :where() only in the Firefox 52 build, where the selector is invalid. */
|
||||
export default function firefox52Where() {
|
||||
return {
|
||||
postcssPlugin: "postcss-firefox52-where",
|
||||
Rule(rule) {
|
||||
if (!rule.selector.includes(":where(")) return;
|
||||
rule.selector = selectorParser((root) => {
|
||||
const selectors = [...root.nodes];
|
||||
root.removeAll();
|
||||
for (const selector of selectors) {
|
||||
for (const expanded of expandWhere(selector)) root.append(expanded);
|
||||
}
|
||||
}).processSync(rule.selector);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
firefox52Where.postcss = true;
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
|
||||
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
|
||||
const password = process.env.FIREFOX_TEST_PASSWORD || "";
|
||||
|
||||
async function selectAgentId() {
|
||||
if (process.env.FIREFOX_TEST_AGENT_ID) return process.env.FIREFOX_TEST_AGENT_ID;
|
||||
const loginUrl = new URL(`${backendUrl}/api/v1/auth/login/username`);
|
||||
if (password) loginUrl.searchParams.set("password", password);
|
||||
const loginResponse = await fetch(loginUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!loginResponse.ok) throw new Error(`测试账号登录失败:HTTP ${loginResponse.status}`);
|
||||
const auth = await loginResponse.json();
|
||||
const selectorResponse = await fetch(`${backendUrl}/api/agents?admin=true`, {
|
||||
headers: { Authorization: `${auth.token_type || "Bearer"} ${auth.access_token}` },
|
||||
});
|
||||
if (!selectorResponse.ok) throw new Error(`读取智能体列表失败:HTTP ${selectorResponse.status}`);
|
||||
const payload = await selectorResponse.json();
|
||||
const agents = Array.isArray(payload.agents) ? payload.agents : [];
|
||||
const agent = agents.find((item) => item?.id && item?.model) || agents.find((item) => item?.id);
|
||||
if (!agent) throw new Error("智能体列表中没有可用于流式验收的智能体");
|
||||
return String(agent.id);
|
||||
}
|
||||
|
||||
const agentId = await selectAgentId();
|
||||
const executable = process.execPath;
|
||||
const child = spawn(executable, [path.join(rootDir, "scripts", "smoke-firefox52.mjs"), "--live-only"], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
FIREFOX_LIVE_CHAT_HASH: `#/page/workspace/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
FIREFOX_LIVE_CHAT_NAME: `真实智能体流式问答(${agentId})`,
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => resolve(code ?? 1));
|
||||
});
|
||||
process.exitCode = exitCode;
|
||||
@@ -1,377 +0,0 @@
|
||||
import net from "node:net";
|
||||
|
||||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||||
const backendUrl = (process.env.FIREFOX_BACKEND_URL || "http://localhost:8001").replace(/\/$/, "");
|
||||
const username = process.env.FIREFOX_TEST_USERNAME || "admin";
|
||||
const password = process.env.FIREFOX_TEST_PASSWORD || "";
|
||||
const routeTimeoutMs = Number(process.env.FIREFOX_ROUTE_TIMEOUT_MS || 30_000);
|
||||
const fullReload = process.env.FIREFOX_FULL_RELOAD === "1";
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Static routes that can be verified without creating or mutating business
|
||||
* records. Parameterised workflows need their own test data; this list
|
||||
* intentionally rejects unexpected auth redirects.
|
||||
*/
|
||||
const routes = [
|
||||
// Public and standalone shells.
|
||||
["登录页鉴权恢复", `#/login/${encodeURIComponent(username)}`, { redirect: "#/page/workspace/chats/new" }],
|
||||
["落地页", "#/landing"],
|
||||
["公开排行榜", "#/public/leaderboard"],
|
||||
["会商大屏", "#/roundtable-dashboard"],
|
||||
["Official DeerFlow", "#/official"],
|
||||
|
||||
// Workspace routes.
|
||||
["工作区首页", "#/page/workspace/chats/new"],
|
||||
["对话列表", "#/page/workspace/chats"],
|
||||
["项目入口", "#/page/workspace/projects", { redirect: "#/page/strategy/qa/projects" }],
|
||||
["资料库入口", "#/page/workspace/library"],
|
||||
["微信新对话", "#/page/workspace/wx/chats/new"],
|
||||
["微信智能体", "#/page/workspace/wx/agents"],
|
||||
["微信技能", "#/page/workspace/wx/skills"],
|
||||
["智能体列表", "#/page/workspace/agents"],
|
||||
["新建智能体", "#/page/workspace/agents/new"],
|
||||
["智能体协作网络", "#/page/workspace/agents/orbit"],
|
||||
["智能体广场", "#/page/workspace/agents/plaza"],
|
||||
["技能列表", "#/page/workspace/skills"],
|
||||
["技能进化", "#/page/workspace/skills/evolution"],
|
||||
["AI 写作", "#/page/workspace/ai-writing"],
|
||||
["深度研究", "#/page/workspace/deep-research"],
|
||||
["公文助手", "#/page/workspace/gongwen"],
|
||||
["新建报告结构", "#/page/workspace/deep-research/structures/new"],
|
||||
["企业研究", "#/page/workspace/enterprise-research"],
|
||||
["Notebook 列表", "#/page/workspace/studio/notebooks"],
|
||||
["多智能体规划", "#/page/workspace/roundtable/planning"],
|
||||
["8BF 会商", "#/page/workspace/roundtable/8bf"],
|
||||
["岗位会商", "#/page/workspace/roundtable/position"],
|
||||
["业务链编辑", "#/page/workspace/roundtable/chains"],
|
||||
["业务链列表", "#/page/workspace/business-chains"],
|
||||
["报告协作", "#/page/workspace/report-collaboration"],
|
||||
["派活链路预览", "#/page/workspace/roundtable/chain-preview"],
|
||||
["知识空间", "#/page/workspace/knowledge"],
|
||||
["知识图谱配置回退", "#/page/workspace/knowledge/graph", { redirect: "#/page/workspace/knowledge" }],
|
||||
["助手知识库配置回退", "#/page/workspace/knowledge/assistant", { redirect: "#/page/workspace/knowledge" }],
|
||||
["知识检索调试配置回退", "#/page/workspace/knowledge/retrieval-debug", { redirect: "#/page/workspace/knowledge" }],
|
||||
["Markdown 转 Word", "#/page/workspace/knowledge/markdown-editor"],
|
||||
["定时任务", "#/page/workspace/scheduled/tasks"],
|
||||
|
||||
// Workspace admin/settings surfaces.
|
||||
["用户管理中心", "#/page/workspace/admin"],
|
||||
["管理员定时任务", "#/page/workspace/admin/scheduled-tasks"],
|
||||
["管理员智能体", "#/page/workspace/admin/agents"],
|
||||
["管理员技能", "#/page/workspace/admin/skills"],
|
||||
["技能定时", "#/page/workspace/admin/skill-timing"],
|
||||
["模型交换", "#/page/workspace/admin/model-exchanges"],
|
||||
["用户排行榜管理", "#/page/workspace/admin/user-leaderboard"],
|
||||
["用户排行榜设置", "#/page/workspace/admin/user-leaderboard/settings"],
|
||||
["菜单管理", "#/page/workspace/admin/menu-management"],
|
||||
["固定问题", "#/page/workspace/admin/fixed-questions"],
|
||||
["标签管理", "#/page/workspace/admin/tags"],
|
||||
["插件管理", "#/page/workspace/admin/plugins"],
|
||||
["岗位管理", "#/page/workspace/admin/positions"],
|
||||
["岗位角色", "#/page/workspace/admin/position-roles"],
|
||||
["推荐问题", "#/page/workspace/settings/recommended-questions"],
|
||||
["提示词前缀", "#/page/workspace/settings/prompt-prefix"],
|
||||
["私有广场", "#/page/workspace/settings/private-square"],
|
||||
["模型管理", "#/page/workspace/admin/models"],
|
||||
["模型并发", "#/page/workspace/settings/model-concurrency"],
|
||||
["广场发布", "#/page/workspace/settings/publish-squares"],
|
||||
["技能压缩", "#/page/workspace/settings/skill-compression"],
|
||||
["LLM 指标", "#/page/workspace/settings/llm-metrics"],
|
||||
["Checkpoint 迁移", "#/page/workspace/settings/checkpoint-migration"],
|
||||
|
||||
// Strategy routes and legacy/full-layout entry points.
|
||||
["通用问答", "#/page/strategy/qa/general"],
|
||||
["分析项目", "#/page/strategy/qa/projects"],
|
||||
["方案项目", "#/page/strategy/qa/projects/solutions", { redirect: "#/page/strategy/qa/projects?type=solution" }],
|
||||
["问答项目", "#/page/strategy/qa/projects/groups", { redirect: "#/page/strategy/qa/projects?type=qa" }],
|
||||
["策略选择配置回退", "#/page/strategy/qa/strategy-selection", { redirect: "#/page/strategy/qa/projects" }],
|
||||
["专家问答", "#/page/strategy/qa/expert-qa"],
|
||||
["策略多智能体会商", "#/page/strategy/qa/multi-agent-roundtable"],
|
||||
["策略 8BF 会商", "#/page/strategy/qa/8bf-roundtable"],
|
||||
["策略岗位会商", "#/page/strategy/qa/position-roundtable"],
|
||||
["策略深度研究", "#/page/strategy/qa/deep-research"],
|
||||
["策略公文助手", "#/page/strategy/qa/gongwen"],
|
||||
["策略新建报告结构", "#/page/strategy/qa/deep-research/structures/new"],
|
||||
["策略企业研究", "#/page/strategy/qa/enterprise-research"],
|
||||
["智能体看板", "#/page/strategy/dashboard-agent"],
|
||||
["任务管理", "#/page/strategy/task-management"],
|
||||
["三情分析任务", "#/page/strategy/task-management/plan-task-sentiment"],
|
||||
["任务分析", "#/page/strategy/task-management/plan-task/plan-task-fx"],
|
||||
["行动分析", "#/page/strategy/task-management/plan-task/plan-task-action"],
|
||||
["任务三情会商", "#/page/strategy/task-management/plan-task-sentiment/roundtable"],
|
||||
["任务分析会商", "#/page/strategy/task-management/plan-task/plan-task-fx/roundtable"],
|
||||
["行动分析会商", "#/page/strategy/task-management/plan-task/plan-task-action/roundtable"],
|
||||
["网络行为体资源", "#/page/strategy/resource-management/resource-network-actor"],
|
||||
["知识库资源", "#/page/strategy/resource-management/resource-knowledge-base"],
|
||||
["评价指标资源", "#/page/strategy/resource-management/resource-evaluation-index"],
|
||||
["轻应用管理", "#/page/strategy/light-app/management"],
|
||||
["轻应用列表", "#/page/strategy/light-app/apps"],
|
||||
["轻应用新建笔记", "#/page/strategy/light-app/new-note"],
|
||||
["轻应用笔记", "#/page/strategy/light-app/notes"],
|
||||
["轻应用笔记问答", "#/page/strategy/light-app/note-qa"],
|
||||
["策略对话", "#/page/strategy/chats"],
|
||||
["策略智能体", "#/page/strategy/agents"],
|
||||
["策略智能体广场", "#/page/strategy/agents/plaza"],
|
||||
["策略新建智能体", "#/page/strategy/agents/new"],
|
||||
["策略技能", "#/page/strategy/skills"],
|
||||
["策略技能进化", "#/page/strategy/skills/evolution"],
|
||||
["业务链映射", "#/page/strategy/business-chain-mapping"],
|
||||
["策略业务链列表", "#/page/strategy/business-chains"],
|
||||
["策略业务链编辑", "#/page/strategy/roundtable/chains"],
|
||||
["策略定时任务", "#/page/strategy/scheduled/tasks"],
|
||||
["登录白名单", "#/page/strategy/admin/login-whitelist"],
|
||||
["会商诊断", "#/page/strategy/admin/roundtable-diagnostics"],
|
||||
["技能地址", "#/page/strategy/admin/skill-addresses"],
|
||||
["敏感词权限回退", "#/page/strategy/admin/sensitive-words", { redirect: "#/page/workspace/chats/new" }],
|
||||
["任务按钮", "#/page/strategy/admin/task-buttons"],
|
||||
["策略管理员定时任务", "#/page/strategy/admin/scheduled-tasks"],
|
||||
["策略排行榜", "#/page/strategy/user-leaderboard"],
|
||||
["策略用户活动", "#/page/strategy/admin/user-activity"],
|
||||
["策略标签", "#/page/strategy/admin/tags"],
|
||||
["策略岗位", "#/page/strategy/admin/positions"],
|
||||
|
||||
// Parallel canvas/notebook/roundtable route trees.
|
||||
["画布首页", "#/page/canvas"],
|
||||
["画布 AI 写作", "#/page/canvas/ai-writing"],
|
||||
["对话版 AI 写作", "#/page/canvas/ai-writing-chat"],
|
||||
["文章类型", "#/page/canvas/article-types"],
|
||||
["研究模板", "#/page/canvas/research-templates"],
|
||||
["新建研究模板", "#/page/canvas/research-templates/new"],
|
||||
["Notebook AI 写作", "#/page/notebook/ai-writing"],
|
||||
["独立会商", "#/page/roundtable/planning"],
|
||||
// Keep isolated/embed routes last because their sessionStorage contract is
|
||||
// intentionally sticky across in-app navigation.
|
||||
["iframe 新对话", "#/page/workspace/chats_iframe/new?embed=1&hideJump=1&theme=light", { redirect: "#/page/workspace/chats/new?embed=1&hideJump=1&theme=light&chatMode=full" }],
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const browserErrors = [];
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let rootActor;
|
||||
let consoleActor;
|
||||
let pendingEvaluation;
|
||||
|
||||
function send(packet) {
|
||||
const body = Buffer.from(JSON.stringify(packet));
|
||||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingEvaluation = { expression, resolve, reject };
|
||||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIgnoreConsoleError(message) {
|
||||
return (
|
||||
message.includes("validateDOMNesting") ||
|
||||
message.includes("init intent failed") ||
|
||||
message.includes("ResizeObserver loop") ||
|
||||
message === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
function formatConsoleArgument(argument) {
|
||||
if (argument == null || typeof argument !== "object") return String(argument);
|
||||
const preview = argument.preview;
|
||||
if (preview?.message) {
|
||||
return `${preview.name || argument.class || "Error"}: ${preview.message}${preview.stack ? `\n${preview.stack}` : ""}`;
|
||||
}
|
||||
if (argument.class === "Error" && argument.ownPropertyLength === 0) return "Error";
|
||||
if (typeof argument.description === "string") return argument.description;
|
||||
try {
|
||||
return JSON.stringify(argument);
|
||||
} catch {
|
||||
return String(argument);
|
||||
}
|
||||
}
|
||||
|
||||
function handle(packet) {
|
||||
if (packet.applicationType && !rootActor) {
|
||||
rootActor = packet.from;
|
||||
send({ to: rootActor, type: "listTabs" });
|
||||
return;
|
||||
}
|
||||
if (packet.tabs && !consoleActor) {
|
||||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||||
send({
|
||||
to: consoleActor,
|
||||
type: "startListeners",
|
||||
listeners: ["PageError", "ConsoleAPI"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
packet.type === "pageError" &&
|
||||
packet.pageError &&
|
||||
packet.pageError.category !== "CSS Parser" &&
|
||||
packet.pageError.warning !== true
|
||||
) {
|
||||
const pageError = packet.pageError;
|
||||
const errorText = String(pageError.errorMessage || pageError.message || "").trim();
|
||||
const usefulErrorText = /^Error:\s*$/.test(errorText) ? "" : errorText;
|
||||
// Firefox 52 emits a duplicate empty promise-rejection packet when an XHR
|
||||
// owned by the previous document is cancelled during reload. It has no
|
||||
// stack and no message; meaningful runtime failures carry one or both.
|
||||
if (!usefulErrorText && !pageError.stacktrace) return;
|
||||
browserErrors.push(
|
||||
`PAGE: ${usefulErrorText || JSON.stringify(pageError)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||||
const message = (packet.message.arguments || [])
|
||||
.map(formatConsoleArgument)
|
||||
.join(" ") || "error";
|
||||
if (!shouldIgnoreConsoleError(message)) browserErrors.push(`CONSOLE: ${message}`);
|
||||
return;
|
||||
}
|
||||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||||
const pending = pendingEvaluation;
|
||||
pendingEvaluation = null;
|
||||
if (packet.exception) pending.reject(new Error(String(packet.exception)));
|
||||
else pending.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
function consume() {
|
||||
while (true) {
|
||||
const colon = buffer.indexOf(58);
|
||||
if (colon < 0) return;
|
||||
const length = Number(buffer.subarray(0, colon).toString());
|
||||
if (buffer.length < colon + 1 + length) return;
|
||||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||||
buffer = buffer.subarray(colon + 1 + length);
|
||||
handle(packet);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
consume();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
while (!consoleActor) await sleep(50);
|
||||
|
||||
async function waitFor(expression, timeoutMs = routeTimeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await evaluate(expression)) return true;
|
||||
} catch {
|
||||
// A full reload can invalidate the previous Firefox console actor for a
|
||||
// few milliseconds. Retry against the actor once it resumes.
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function installAdminSession() {
|
||||
const url = new URL(`${backendUrl}/api/v1/auth/login/username`);
|
||||
if (password) url.searchParams.set("password", password);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`测试账号登录失败:HTTP ${response.status}`);
|
||||
}
|
||||
const auth = await response.json();
|
||||
if (auth.system_role !== "admin") {
|
||||
throw new Error(`测试账号 ${username} 不是管理员,无法验证管理路由`);
|
||||
}
|
||||
await evaluate(`(function(){var auth=${JSON.stringify(JSON.stringify(auth))},info=${JSON.stringify(JSON.stringify({ userName: username }))},settings={};try{settings=JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}')}catch(e){}settings.display=settings.display||{};settings.display.layoutMode='full';localStorage.setItem('deerflow.local-settings',JSON.stringify(settings));localStorage.setItem('deerflow.auth',auth);localStorage.setItem('userInfo',info);sessionStorage.setItem('deerflow.auth',auth);sessionStorage.setItem('userInfo',info);return true})()`);
|
||||
}
|
||||
|
||||
async function exerciseVisibleTabs() {
|
||||
const count = Number(await evaluate(`(function(){return [].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}).length})()`));
|
||||
let clicks = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const clicked = await evaluate(`(function(){var items=[].slice.call(document.querySelectorAll('[role="tab"]')).filter(function(item){var rect=item.getBoundingClientRect(),style=getComputedStyle(item);return !item.disabled&&item.getAttribute('aria-disabled')!=='true'&&rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'}),item=items[${index}];if(!item)return false;item.click();return true})()`);
|
||||
if (!clicked) continue;
|
||||
clicks += 1;
|
||||
await sleep(120);
|
||||
const healthy = await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),text=document.body.innerText||'';return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
|
||||
if (!healthy) return { pass: false, clicks, detail: `点击第 ${index + 1} 个标签页后进入错误边界` };
|
||||
}
|
||||
return { pass: true, clicks };
|
||||
}
|
||||
|
||||
async function navigate(hash, options = {}) {
|
||||
const expectedHash = options.redirect || hash;
|
||||
const url = new URL(hash, baseUrl).href;
|
||||
try {
|
||||
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)},reload=${JSON.stringify(fullReload)};if(reload){if(location.href!==url)location.href=url;location.reload()}else if(location.href!==url){location.href=url}return true})()`);
|
||||
} catch {
|
||||
// Firefox destroys the old console evaluation context during a successful
|
||||
// full-page reload and occasionally reports that as an opaque exception.
|
||||
// The route/readiness assertion below is the source of truth.
|
||||
}
|
||||
await sleep(700);
|
||||
const ready = await waitFor(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect(),hasSurface=document.body.innerText.trim().length>0||!!document.querySelector('iframe,canvas,svg,.cm-editor');return location.hash.indexOf(${JSON.stringify(expectedHash)})===0&&document.readyState==='complete'&&!!root&&rect.width>0&&rect.height>0&&root.innerHTML.length>100&&hasSurface})()`);
|
||||
if (!ready) {
|
||||
const state = await evaluate(`(function(){function role(store){try{var value=store.getItem('deerflow.auth');return value?JSON.parse(value).system_role:'none'}catch(e){return 'invalid'}}return location.hash+';local='+role(localStorage)+';session='+role(sessionStorage)+';embed='+sessionStorage.getItem('embed.session')})()`);
|
||||
return `页面未在 ${Math.round(routeTimeoutMs / 1000)} 秒内完成渲染(${state})`;
|
||||
}
|
||||
const healthy = await evaluate(`(function(){var text=document.body.innerText||'';return !/something went wrong|ChunkLoadError|Loading chunk [^ ]+ failed|SyntaxError:|ReferenceError:|TypeError:/i.test(text)})()`);
|
||||
return healthy === true ? true : "页面进入错误边界";
|
||||
}
|
||||
|
||||
try {
|
||||
await installAdminSession();
|
||||
const routeFilter = process.env.FIREFOX_ROUTE_FILTER
|
||||
? new RegExp(process.env.FIREFOX_ROUTE_FILTER, "i")
|
||||
: null;
|
||||
const selectedRoutes = routeFilter
|
||||
? routes.filter(([name, hash]) => routeFilter.test(`${name} ${hash}`))
|
||||
: routes;
|
||||
if (selectedRoutes.length === 0) {
|
||||
throw new Error("FIREFOX_ROUTE_FILTER 未匹配到路由");
|
||||
}
|
||||
for (const [name, hash, options = {}] of selectedRoutes) {
|
||||
// Let late callbacks from the previous page settle before assigning
|
||||
// errors to the next full-page navigation.
|
||||
await sleep(350);
|
||||
const errorStart = browserErrors.length;
|
||||
let detail;
|
||||
try {
|
||||
detail = await navigate(hash, options);
|
||||
if (detail === true) {
|
||||
const tabResult = await exerciseVisibleTabs();
|
||||
if (!tabResult.pass) detail = tabResult.detail;
|
||||
else results.tabClicks = (results.tabClicks || 0) + tabResult.clicks;
|
||||
}
|
||||
} catch (error) {
|
||||
detail = String(error);
|
||||
}
|
||||
// Allow asynchronous render errors to arrive before assigning them to the
|
||||
// next route. Network/API failures are displayed by the page and do not
|
||||
// become PageError events; uncaught runtime errors still fail the route.
|
||||
await sleep(150);
|
||||
const errors = browserErrors
|
||||
.slice(errorStart)
|
||||
.filter((message) => !message.startsWith("CONSOLE: Warning:"));
|
||||
const pass = detail === true && errors.length === 0;
|
||||
results.push({ name, hash, pass, detail, errors });
|
||||
console.log(`${pass ? "PASS" : "FAIL"} ${name}${pass ? "" : ` — ${detail}`}`);
|
||||
for (const error of errors) console.log(` ${error}`);
|
||||
}
|
||||
} finally {
|
||||
socket.end();
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
console.log(`\nFirefox 52 full route smoke: ${results.length - failed.length}/${results.length} passed`);
|
||||
console.log(`Non-mutating visible tab interactions: ${results.tabClicks || 0}`);
|
||||
console.log(`Navigation mode: ${fullReload ? "full reload" : "SPA"}`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
@@ -1,163 +0,0 @@
|
||||
import net from "node:net";
|
||||
|
||||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const themes = ["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"];
|
||||
const layouts = ["full", "compact"];
|
||||
const results = [];
|
||||
const browserErrors = [];
|
||||
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let rootActor;
|
||||
let consoleActor;
|
||||
let pendingEvaluation;
|
||||
|
||||
function send(packet) {
|
||||
const body = Buffer.from(JSON.stringify(packet));
|
||||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingEvaluation = { expression, resolve, reject };
|
||||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||||
});
|
||||
}
|
||||
|
||||
function formatArgument(argument) {
|
||||
if (argument == null || typeof argument !== "object") return String(argument);
|
||||
if (argument.preview?.message) return `${argument.preview.name || argument.class || "Error"}: ${argument.preview.message}`;
|
||||
return argument.description || argument.class || JSON.stringify(argument);
|
||||
}
|
||||
|
||||
function handle(packet) {
|
||||
if (packet.applicationType && !rootActor) {
|
||||
rootActor = packet.from;
|
||||
send({ to: rootActor, type: "listTabs" });
|
||||
return;
|
||||
}
|
||||
if (packet.tabs && !consoleActor) {
|
||||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||||
send({ to: consoleActor, type: "startListeners", listeners: ["PageError", "ConsoleAPI"] });
|
||||
return;
|
||||
}
|
||||
if (packet.type === "pageError" && packet.pageError && packet.pageError.warning !== true && packet.pageError.category !== "CSS Parser") {
|
||||
const message = String(packet.pageError.errorMessage || packet.pageError.message || "").trim();
|
||||
if (message || packet.pageError.stacktrace) browserErrors.push(`PAGE: ${message || "unknown page error"}`);
|
||||
return;
|
||||
}
|
||||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||||
const message = (packet.message.arguments || []).map(formatArgument).join(" ") || "error";
|
||||
if (!message.includes("validateDOMNesting") && !message.includes("init intent failed") && !message.includes("ResizeObserver loop") && message !== "[object Object]") {
|
||||
browserErrors.push(`CONSOLE: ${message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||||
const pending = pendingEvaluation;
|
||||
pendingEvaluation = null;
|
||||
if (packet.exception) pending.reject(new Error(packet.exceptionMessage || packet.exception?.description || String(packet.exception)));
|
||||
else pending.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
function consume() {
|
||||
while (true) {
|
||||
const colon = buffer.indexOf(58);
|
||||
if (colon < 0) return;
|
||||
const length = Number(buffer.subarray(0, colon).toString());
|
||||
if (buffer.length < colon + 1 + length) return;
|
||||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||||
buffer = buffer.subarray(colon + 1 + length);
|
||||
handle(packet);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
consume();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
while (!consoleActor) await sleep(50);
|
||||
|
||||
async function waitFor(expression, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await evaluate(expression)) return true;
|
||||
} catch {
|
||||
// A reload briefly invalidates the old evaluation context.
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const layout of layouts) {
|
||||
for (const theme of themes) {
|
||||
const errorStart = browserErrors.length;
|
||||
let detail = true;
|
||||
try {
|
||||
const settings = JSON.stringify({
|
||||
notification: { enabled: true },
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
artifactDirectoryModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
fontScale: 1,
|
||||
layoutMode: layout,
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
const url = new URL("#/page/workspace/chats/new", baseUrl).href;
|
||||
try {
|
||||
await evaluate(`(function(){localStorage.setItem('strategy-theme',${JSON.stringify(theme)});localStorage.setItem('strategy-theme-user-set','1');localStorage.setItem('deerflow.local-settings',${JSON.stringify(settings)});sessionStorage.removeItem('embed.session');location.href=${JSON.stringify(url)};location.reload();return true})()`);
|
||||
} catch {
|
||||
// A successful full reload can destroy the old console request.
|
||||
}
|
||||
// The hash route is identical for every matrix item. Give Firefox time
|
||||
// to tear down the previous document before accepting a ready DOM.
|
||||
await sleep(700);
|
||||
const ready = await waitFor(`(function(){var root=document.getElementById('root'),textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');return location.hash.indexOf('#/page/workspace/chats/new')===0&&document.readyState==='complete'&&!!root&&root.getBoundingClientRect().width>0&&!!textarea&&textarea.getBoundingClientRect().height>0})()`);
|
||||
if (!ready) {
|
||||
detail = "页面未在 30 秒内完成渲染";
|
||||
} else {
|
||||
const state = await evaluate(`(function(){var html=document.documentElement,root=document.getElementById('root'),textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]'),style=getComputedStyle(html),sidebar=document.querySelector('[data-sidebar="sidebar"],[data-compact-guide="sidebar-nav"]');return JSON.stringify({classes:html.className,dataTheme:html.getAttribute('data-theme')||'',themeMode:html.getAttribute('theme-mode')||'',storedTheme:localStorage.getItem('strategy-theme'),layout:JSON.parse(localStorage.getItem('deerflow.local-settings')||'{}').display.layoutMode,rootWidth:Math.round(root.getBoundingClientRect().width),inputWidth:Math.round(textarea.getBoundingClientRect().width),sidebarVisible:!!sidebar&&sidebar.getBoundingClientRect().width>0,background:style.getPropertyValue('--background').trim(),foreground:style.getPropertyValue('--foreground').trim(),primary:style.getPropertyValue('--primary').trim()})})()`);
|
||||
const parsed = JSON.parse(state);
|
||||
const themeApplied = parsed.storedTheme === theme && parsed.classes.split(/\s+/).includes(theme);
|
||||
const modeApplied = theme === "dark"
|
||||
? parsed.themeMode === "dark" && parsed.classes.split(/\s+/).includes("dark")
|
||||
: theme === "light"
|
||||
? parsed.dataTheme === "" && parsed.themeMode === ""
|
||||
: parsed.dataTheme === theme && parsed.themeMode === "";
|
||||
const tokensApplied = parsed.background && parsed.foreground && parsed.primary;
|
||||
const layoutApplied = parsed.layout === layout;
|
||||
const dimensionsHealthy = parsed.rootWidth > 0 && parsed.inputWidth > 200;
|
||||
if (!themeApplied || !modeApplied || !tokensApplied || !layoutApplied || !dimensionsHealthy) detail = state;
|
||||
}
|
||||
} catch (error) {
|
||||
detail = String(error);
|
||||
}
|
||||
await sleep(150);
|
||||
const errors = browserErrors.slice(errorStart);
|
||||
const pass = detail === true && errors.length === 0;
|
||||
results.push({ theme, layout, pass, detail, errors });
|
||||
console.log(`${pass ? "PASS" : "FAIL"} ${layout}/${theme}${pass ? "" : ` — ${detail}`}`);
|
||||
for (const error of errors) console.log(` ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const viewport = await evaluate(`JSON.stringify({innerWidth:window.innerWidth,innerHeight:window.innerHeight,devicePixelRatio:window.devicePixelRatio,screenWidth:screen.width,screenHeight:screen.height})`);
|
||||
socket.end();
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
console.log(`\nFirefox 52 theme/layout smoke: ${results.length - failed.length}/${results.length} passed`);
|
||||
console.log(`Viewport: ${viewport}`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
@@ -1,294 +0,0 @@
|
||||
import net from "node:net";
|
||||
|
||||
const debugPort = Number(process.env.FIREFOX_DEBUG_PORT || 6001);
|
||||
const baseUrl = process.env.FIREFOX_BASE_URL || "http://localhost:5174/";
|
||||
const liveOnly = process.argv.includes("--live-only");
|
||||
const liveMode = liveOnly || process.argv.includes("--live");
|
||||
const liveChatHash = process.env.FIREFOX_LIVE_CHAT_HASH || "#/page/workspace/chats/new";
|
||||
const liveChatName = process.env.FIREFOX_LIVE_CHAT_NAME || "真实流式问答";
|
||||
const liveThreadPrefix = liveChatHash.replace(/\/new(?:\?.*)?$/, "/");
|
||||
const smokeFilter = process.env.FIREFOX_SMOKE_FILTER
|
||||
? new RegExp(process.env.FIREFOX_SMOKE_FILTER, "i")
|
||||
: null;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const routes = [
|
||||
["新对话", "#/page/workspace/chats/new", `!!document.querySelector('textarea[placeholder="今天我能为你做些什么?"]')`],
|
||||
["对话", "#/page/workspace/chats", `!!document.querySelector('input[type=search]')`],
|
||||
["我的项目", "#/page/strategy/qa/projects", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='新建项目'})`],
|
||||
["AI 写作", "#/page/workspace/ai-writing", `location.hash.indexOf('#/page/workspace/ai-writing')===0&&!!document.querySelector('textarea[placeholder="今天我能为你做些什么?"]')`],
|
||||
["多智能体规划", "#/page/workspace/roundtable/planning", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='业务链条'})`],
|
||||
["定时任务", "#/page/workspace/scheduled/tasks", `[].slice.call(document.querySelectorAll('button')).some(function(e){return e.innerText.trim()==='新建'})`],
|
||||
["md 转 Word", "#/page/workspace/knowledge/markdown-editor", `!!document.querySelector('.cm-editor')`],
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const browserErrors = [];
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: debugPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let rootActor;
|
||||
let consoleActor;
|
||||
let pendingEvaluation;
|
||||
|
||||
function send(packet) {
|
||||
const body = Buffer.from(JSON.stringify(packet));
|
||||
socket.write(Buffer.concat([Buffer.from(`${body.length}:`), body]));
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingEvaluation = { expression, resolve, reject };
|
||||
send({ to: consoleActor, type: "evaluateJS", text: expression });
|
||||
});
|
||||
}
|
||||
|
||||
function handle(packet) {
|
||||
if (packet.applicationType && !rootActor) {
|
||||
rootActor = packet.from;
|
||||
send({ to: rootActor, type: "listTabs" });
|
||||
return;
|
||||
}
|
||||
if (packet.tabs && !consoleActor) {
|
||||
consoleActor = packet.tabs[packet.selected].consoleActor;
|
||||
send({
|
||||
to: consoleActor,
|
||||
type: "startListeners",
|
||||
listeners: ["PageError", "ConsoleAPI"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
packet.type === "pageError" &&
|
||||
packet.pageError?.error &&
|
||||
packet.pageError.category !== "CSS Parser"
|
||||
) {
|
||||
browserErrors.push(`PAGE: ${packet.pageError.errorMessage}`);
|
||||
return;
|
||||
}
|
||||
if (packet.type === "consoleAPICall" && packet.message?.level === "error") {
|
||||
const message = String(packet.message.arguments?.[0] ?? "error");
|
||||
// Existing scheduled-task cards contain a nested action button. React's
|
||||
// development-only warning is unrelated to Firefox execution.
|
||||
if (
|
||||
!message.includes("validateDOMNesting") &&
|
||||
!message.includes("init intent failed") &&
|
||||
message !== "Unhandled promise rejection" &&
|
||||
message !== "[object Object]"
|
||||
) {
|
||||
browserErrors.push(`CONSOLE: ${message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingEvaluation && packet.input === pendingEvaluation.expression) {
|
||||
const pending = pendingEvaluation;
|
||||
pendingEvaluation = null;
|
||||
if (packet.exception) {
|
||||
const detail =
|
||||
packet.exceptionMessage ||
|
||||
packet.exception?.preview?.message ||
|
||||
packet.exception?.description ||
|
||||
packet.exception?.class ||
|
||||
String(packet.exception);
|
||||
pending.reject(new Error(String(detail)));
|
||||
}
|
||||
else pending.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
function consume() {
|
||||
while (true) {
|
||||
const colon = buffer.indexOf(58);
|
||||
if (colon < 0) return;
|
||||
const length = Number(buffer.subarray(0, colon).toString());
|
||||
if (buffer.length < colon + 1 + length) return;
|
||||
const packet = JSON.parse(buffer.subarray(colon + 1, colon + 1 + length).toString());
|
||||
buffer = buffer.subarray(colon + 1 + length);
|
||||
handle(packet);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
consume();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
while (!consoleActor) await sleep(50);
|
||||
|
||||
async function waitFor(expression, timeoutMs = 20_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate(expression)) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigate(hash, readyExpression = "true") {
|
||||
const url = new URL(hash, baseUrl).href;
|
||||
await evaluate(`(function(){sessionStorage.removeItem('embed.session');sessionStorage.removeItem('embed.session.nested');try{delete window.__deerflowTopChromeEmbed}catch(e){}var url=${JSON.stringify(url)};if(location.href===url)location.reload();else location.href=url;return 'navigate'})()`);
|
||||
// HashRouter keeps the previous route mounted for a short time after the URL
|
||||
// changes. Do not mistake that stale DOM for the destination being ready.
|
||||
await sleep(700);
|
||||
return waitFor(`(function(){var root=document.getElementById('root');return location.hash.indexOf(${JSON.stringify(hash)})===0&&document.readyState==='complete'&&!!root&&root.innerHTML.length>100&&document.body.innerText.length>20&&(${readyExpression})})()`);
|
||||
}
|
||||
|
||||
const mouseClick = `(function(element){if(!element)return false;['mousemove','mousedown','mouseup','click'].forEach(function(type){element.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type==='mousedown'?1:0}))});return true})`;
|
||||
const clickText = (text, prefix = false) =>
|
||||
`(function(){var text=${JSON.stringify(text)},items=[].slice.call(document.querySelectorAll('button,[role=menuitem],[role=tab]'));var element=items.filter(function(item){var value=(item.innerText||'').trim(),rect=item.getBoundingClientRect(),style=getComputedStyle(item);return rect.width>0&&rect.height>0&&style.display!=='none'&&style.visibility!=='hidden'&&${prefix ? "value.indexOf(text)===0" : "value===text"}})[0];return ${mouseClick}(element)})()`;
|
||||
const clickTitle = (title) =>
|
||||
`(function(){var title=${JSON.stringify(title)},element=[].slice.call(document.querySelectorAll('[title]')).filter(function(item){return item.getAttribute('title')===title})[0];return ${mouseClick}(element)})()`;
|
||||
const clickModelSelector = `(${mouseClick})([].slice.call(document.querySelectorAll('[data-firefox52-smoke="model-selector"]')).filter(function(item){var rect=item.getBoundingClientRect();return rect.width>0&&rect.height>0})[0])`;
|
||||
const visibleText = (selector, text) =>
|
||||
`(function(){var text=${JSON.stringify(text)};return [].slice.call(document.querySelectorAll(${JSON.stringify(selector)})).some(function(e){var r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'&&(e.innerText||'').indexOf(text)>=0})})()`;
|
||||
|
||||
async function check(name, operation) {
|
||||
if (smokeFilter && !smokeFilter.test(name)) return;
|
||||
const errorStart = browserErrors.length;
|
||||
try {
|
||||
const detail = await operation();
|
||||
const errors = browserErrors.slice(errorStart);
|
||||
results.push({ name, pass: detail === true && errors.length === 0, detail, errors });
|
||||
} catch (error) {
|
||||
results.push({ name, pass: false, detail: String(error), errors: browserErrors.slice(errorStart) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!liveOnly) {
|
||||
for (const [name, hash, readyExpression] of routes) {
|
||||
await check(`页面:${name}`, async () => {
|
||||
if (!(await navigate(hash, readyExpression))) return "页面未在 10 秒内完成渲染";
|
||||
const healthy = await evaluate(`(function(){var root=document.getElementById('root');return !!root&&root.getBoundingClientRect().width>0&&root.getBoundingClientRect().height>0&&!/something went wrong|页面加载失败|发生错误/i.test(document.body.innerText)})()`);
|
||||
return healthy || "页面根节点尺寸或错误边界异常";
|
||||
});
|
||||
}
|
||||
|
||||
await check("设置和更多菜单", async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const settingsTrigger = `((${clickText("设置和更多")}))||((${clickTitle("设置和更多")}))`;
|
||||
if (!(await waitFor(settingsTrigger, 15_000))) return "未找到入口";
|
||||
return (await waitFor(visibleText("[role=menu],[data-slot=dropdown-menu-content]", "通知"))) || "菜单未打开";
|
||||
});
|
||||
|
||||
await check("换肤色板", async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const hovered = await evaluate(`(function(){var e=[].slice.call(document.querySelectorAll('[title]')).filter(function(x){return x.getAttribute('title')==='换肤'})[0];if(!e)return false;e.dispatchEvent(new MouseEvent('mouseover',{bubbles:true,view:window}));return true})()`);
|
||||
if (!hovered) return "未找到换肤入口";
|
||||
return (await waitFor(`[].slice.call(document.querySelectorAll('button[aria-label]')).filter(function(x){return ['红色','绿色','蓝色','冰川蓝','浅黄色','深蓝色'].indexOf(x.getAttribute('aria-label'))>=0&&x.getBoundingClientRect().width>0}).length>=6`)) || "色板未显示";
|
||||
});
|
||||
|
||||
for (const [name, button, expected] of [
|
||||
["Agent 选择器", "选择 Agent", "选择 Agent"],
|
||||
["模型选择器", "__model_selector__", "Model Selector"],
|
||||
["常用提示词管理", null, "管理常用提示词"],
|
||||
]) {
|
||||
await check(name, async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const clicked = await evaluate(
|
||||
button === "__model_selector__"
|
||||
? clickModelSelector
|
||||
: button
|
||||
? clickText(button)
|
||||
: clickTitle("管理常用提示词"),
|
||||
);
|
||||
if (!clicked) return "未找到入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", expected))) || "弹窗未打开";
|
||||
});
|
||||
}
|
||||
|
||||
await check("输入框 Enter 提交", async () => {
|
||||
await navigate("#/page/workspace/chats/new", routes[0][2]);
|
||||
const submitted = await evaluate(`(function(){var textarea=document.querySelector('textarea'),form=textarea&&textarea.closest('form');if(!textarea||!form)return false;window.__firefox52SubmitCount=0;form.addEventListener('submit',function(event){event.preventDefault();window.__firefox52SubmitCount+=1},{capture:true,once:true});var setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(textarea,'Firefox 52 Enter smoke test');textarea.dispatchEvent(new Event('input',{bubbles:true}));textarea.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true,cancelable:true}));return true})()`);
|
||||
if (!submitted) return "未找到输入表单";
|
||||
return (await waitFor(`window.__firefox52SubmitCount===1`)) || "Enter 未触发表单提交";
|
||||
});
|
||||
|
||||
await check("对话类型筛选", async () => {
|
||||
await navigate("#/page/workspace/chats", routes[1][2]);
|
||||
if (!(await evaluate(clickText("多智能体会商", true)))) return "未找到筛选按钮";
|
||||
await sleep(300);
|
||||
return (await evaluate(`(function(){var root=document.getElementById('root'),rect=root&&root.getBoundingClientRect();return !!root&&rect.width>0&&rect.height>0&&!/something went wrong|页面加载失败|发生错误/i.test(document.body.innerText)})()`)) || "筛选后页面异常";
|
||||
});
|
||||
|
||||
await check("项目创建模式", async () => {
|
||||
await navigate("#/page/strategy/qa/projects", routes[2][2]);
|
||||
if (!(await evaluate(clickText("新建项目")))) return "未找到新建项目";
|
||||
return (await waitFor(visibleText("[role=dialog]", "选择创建模式"))) || "创建模式弹窗未打开";
|
||||
});
|
||||
|
||||
await check("AI 写作模型选择", async () => {
|
||||
await navigate("#/page/workspace/ai-writing", routes[3][2]);
|
||||
if (!(await evaluate(clickModelSelector))) return "未找到模型入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", "Model Selector"))) || "模型弹窗未打开";
|
||||
});
|
||||
|
||||
await check("多智能体业务链条", async () => {
|
||||
await navigate("#/page/workspace/roundtable/planning", routes[4][2]);
|
||||
if (!(await evaluate(clickText("业务链条")))) return "未找到业务链条入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", "业务链条"))) || "业务链条弹窗未打开";
|
||||
});
|
||||
|
||||
await check("定时任务新建", async () => {
|
||||
await navigate("#/page/workspace/scheduled/tasks", routes[5][2]);
|
||||
if (!(await evaluate(clickText("新建")))) return "未找到新建入口";
|
||||
return (await waitFor(visibleText("[role=dialog]", "新建定时任务"))) || "新建定时任务弹窗未打开";
|
||||
});
|
||||
|
||||
await check("md 转 Word 编辑器", async () => {
|
||||
await navigate("#/page/workspace/knowledge/markdown-editor", routes[6][2]);
|
||||
const ready = await evaluate(`!!document.querySelector('.cm-editor')&&!!document.querySelector('input[placeholder="请输入导出的 Word 标题"]')`);
|
||||
if (!ready) return "编辑器或标题输入框缺失";
|
||||
if (!(await evaluate(clickText("重置")))) return "重置按钮不可用";
|
||||
return true;
|
||||
});
|
||||
|
||||
await check("md 转 Word 实际生成", async () => {
|
||||
await navigate("#/page/workspace/knowledge/markdown-editor", routes[6][2]);
|
||||
const installed = await evaluate(`(function(){window.__firefox52Download=null;window.__firefox52DownloadNetwork=[];window.__firefox52DownloadNotices=[];new MutationObserver(function(){[].slice.call(document.querySelectorAll('[data-sonner-toast],[role="alert"],[role="status"]')).forEach(function(item){var value=(item.innerText||item.textContent||'').trim();if(value&&window.__firefox52DownloadNotices.indexOf(value)<0)window.__firefox52DownloadNotices.push(value)})}).observe(document.body,{childList:true,subtree:true});var originalFetch=window.fetch.bind(window);window.fetch=function(){var args=arguments,item={url:String(args[0]&&args[0].url||args[0]||''),status:'pending'};window.__firefox52DownloadNetwork.push(item);return originalFetch.apply(window,args).then(function(response){item.status=response.status;return response},function(error){item.status='error';item.error=String(error);throw error})};var originalCreate=URL.createObjectURL.bind(URL);URL.createObjectURL=function(blob){window.__firefox52Download={blobSize:blob.size,blobType:blob.type,download:''};return originalCreate(blob)};HTMLAnchorElement.prototype.click=function(){var state=window.__firefox52Download||{};state.download=this.download||'';state.href=this.href||'';window.__firefox52Download=state};return true})()`);
|
||||
if (!installed) return "无法安装下载捕获器";
|
||||
if (!(await evaluate(clickText("下载 Word")))) return "下载 Word 按钮不可用";
|
||||
const generated = await waitFor(`(function(){var item=window.__firefox52Download;return !!item&&item.blobSize>0&&/\\.docx$/i.test(item.download)&&item.blobType==='application/vnd.openxmlformats-officedocument.wordprocessingml.document'})()`, 60_000);
|
||||
if (generated) return true;
|
||||
return await evaluate(`JSON.stringify({download:window.__firefox52Download||null,network:window.__firefox52DownloadNetwork||[],notices:window.__firefox52DownloadNotices||[]})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (liveMode) {
|
||||
await check(liveChatName, async () => {
|
||||
await navigate(liveChatHash, routes[0][2]);
|
||||
const marker = `FIREFOX52_STREAM_OK_${Date.now()}`;
|
||||
const prompt = `请只回复这一行,不要添加其他内容:${marker}`;
|
||||
const tracing = await evaluate(`(function(){window.__firefox52Network=[];window.__firefox52RuntimeErrors=[];window.__firefox52Notices=[];window.addEventListener('error',function(event){window.__firefox52RuntimeErrors.push(String(event.message||event.error||'error'))});window.addEventListener('unhandledrejection',function(event){window.__firefox52RuntimeErrors.push(String(event.reason||'unhandled rejection'))});new MutationObserver(function(){[].slice.call(document.querySelectorAll('[data-sonner-toast],[role="alert"],[role="status"]')).forEach(function(item){var value=(item.innerText||item.textContent||'').trim();if(value&&window.__firefox52Notices.indexOf(value)<0)window.__firefox52Notices.push(value)})}).observe(document.body,{childList:true,subtree:true});if(!window.__firefox52OriginalFetch){window.__firefox52OriginalFetch=window.fetch;window.fetch=function(){var args=arguments,url=String(args[0]&&args[0].url||args[0]||''),init=args[1]||{};window.__firefox52Network.push({kind:'fetch',method:String(init.method||args[0]&&args[0].method||'GET'),url:url,status:'pending'});var item=window.__firefox52Network[window.__firefox52Network.length-1];return window.__firefox52OriginalFetch.apply(this,args).then(function(response){item.status=response.status;if(item.method==='POST'&&item.url.slice(-12)==='/api/threads'){try{response.clone().text().then(function(body){item.body=body.slice(0,500)},function(error){item.cloneError=String(error)})}catch(error){item.cloneError=String(error)}}return response},function(error){item.status='error';item.error=String(error);throw error})}}if(!window.__firefox52OriginalXhrOpen){window.__firefox52OriginalXhrOpen=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){this.__firefox52Trace={kind:'xhr',method:String(method),url:String(url),status:'pending'};window.__firefox52Network.push(this.__firefox52Trace);this.addEventListener('loadend',function(){this.__firefox52Trace.status=this.status});return window.__firefox52OriginalXhrOpen.apply(this,arguments)}}return true})()`);
|
||||
if (!tracing) return "无法安装请求追踪器";
|
||||
const filled = await evaluate(`(function(){var textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');if(!textarea)return false;var setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(textarea,${JSON.stringify(prompt)});textarea.dispatchEvent(new Event('input',{bubbles:true}));return textarea.value===${JSON.stringify(prompt)}})()`);
|
||||
if (!filled) return "未找到问答输入框";
|
||||
// Keep Enter compatibility as its own assertion above. The live check
|
||||
// clicks the real submit control so failures are attributable to the
|
||||
// backend stream rather than to synthetic keyboard-event differences.
|
||||
await sleep(300);
|
||||
const sent = await evaluate(`(function(){var buttons=[].slice.call(document.querySelectorAll('button[aria-label="Submit"]')),button=buttons.filter(function(item){var rect=item.getBoundingClientRect();return rect.width>0&&rect.height>0})[0];if(!button||button.disabled)return false;var form=button.form;if(!form)return false;window.__firefox52SubmitCount=0;form.addEventListener('submit',function(){window.__firefox52SubmitCount+=1},{capture:true});button.click();return true})()`);
|
||||
if (!sent) return "发送按钮不存在或仍处于禁用状态";
|
||||
const enteredThread = await waitFor(`location.hash.indexOf(${JSON.stringify(liveThreadPrefix)})===0&&location.hash.indexOf('/new')<0`, 45_000);
|
||||
if (!enteredThread) {
|
||||
const diagnostic = await evaluate(`(function(){var textarea=document.querySelector('textarea[placeholder="今天我能为你做些什么?"]');return JSON.stringify({hash:location.hash,submitCount:window.__firefox52SubmitCount||0,textarea:textarea&&textarea.value,markerVisible:(document.body.innerText||'').indexOf(${JSON.stringify(marker)})>=0,lastAgent:localStorage.getItem('deerflow.last-agent-id'),pendingAgentMessage:sessionStorage.getItem('pendingAgentMessage'),notices:window.__firefox52Notices||[],runtimeErrors:window.__firefox52RuntimeErrors||[],network:(window.__firefox52Network||[]).slice(-12),text:(document.body.innerText||'').slice(-800)})})()`);
|
||||
return `发送后未进入会话路由:${diagnostic}`;
|
||||
}
|
||||
const streamed = await waitFor(`(function(){var marker=${JSON.stringify(marker)},text=document.body.innerText||'',count=text.split(marker).length-1;return count>=2&&!!document.querySelector('[data-reference-batch-id]')})()`, 180_000);
|
||||
return streamed || "180 秒内未收到包含校验标记的流式回答";
|
||||
});
|
||||
}
|
||||
|
||||
socket.end();
|
||||
|
||||
for (const result of results) {
|
||||
const mark = result.pass ? "PASS" : "FAIL";
|
||||
console.log(`${mark} ${result.name}${result.pass ? "" : ` — ${result.detail}`}`);
|
||||
for (const error of result.errors) console.log(` ${error}`);
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
console.log(`\nFirefox 52 smoke: ${results.length - failed.length}/${results.length} passed`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
@@ -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 { imeEnterAction } from "@/lib/ime";
|
||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadPolicy,
|
||||
validateChatUploadFiles,
|
||||
} from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
@@ -61,12 +61,10 @@ import {
|
||||
type ChangeEventHandler,
|
||||
Children,
|
||||
type ClipboardEventHandler,
|
||||
type ComponentProps,
|
||||
type CompositionEventHandler,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
type PropsWithChildren,
|
||||
@@ -130,11 +128,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);
|
||||
@@ -150,94 +148,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(() => {
|
||||
@@ -432,14 +430,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -485,17 +483,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: (
|
||||
@@ -511,25 +509,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;
|
||||
|
||||
@@ -562,11 +560,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);
|
||||
}
|
||||
@@ -576,72 +574,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) =>
|
||||
@@ -829,17 +827,11 @@ 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
|
||||
? (liveText ?? controller.textInput.value)
|
||||
? controller.textInput.value
|
||||
: (() => {
|
||||
const formData = new FormData(form);
|
||||
return (formData.get("message") as string) || liveText || "";
|
||||
return (formData.get("message") as string) || "";
|
||||
})();
|
||||
|
||||
// Reset form immediately after capturing text to avoid race condition
|
||||
@@ -902,7 +894,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}
|
||||
@@ -917,10 +909,10 @@ export const PromptInput = ({
|
||||
ref={formRef}
|
||||
{...props}
|
||||
>
|
||||
{/* 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">
|
||||
{/* 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">
|
||||
{children}
|
||||
</InputGroup>
|
||||
</form>
|
||||
@@ -942,107 +934,69 @@ export const PromptInputBody = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputBodyProps) => (
|
||||
<div className={cn("contents", className)} {...props} />
|
||||
<div
|
||||
data-slot="prompt-input-body"
|
||||
className={cn("contents", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputTextareaProps = ComponentProps<
|
||||
typeof InputGroupTextarea
|
||||
>;
|
||||
|
||||
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]);
|
||||
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]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
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") {
|
||||
if (isIMEComposing(e, isComposing)) {
|
||||
return;
|
||||
}
|
||||
onKeyDownRef.current?.(e);
|
||||
if (e.defaultPrevented || e.shiftKey) {
|
||||
if (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;
|
||||
@@ -1054,59 +1008,22 @@ 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 === "" &&
|
||||
attachmentsRef.current.files.length > 0
|
||||
attachments.files.length > 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
const lastAttachment = attachmentsRef.current.files.at(-1);
|
||||
const lastAttachment = attachments.files.at(-1);
|
||||
if (lastAttachment) {
|
||||
attachmentsRef.current.remove(lastAttachment.id);
|
||||
attachments.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;
|
||||
|
||||
@@ -1136,28 +1053,35 @@ 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
|
||||
{...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);
|
||||
}}
|
||||
<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);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
{...(controller
|
||||
? { value: controller.textInput.value }
|
||||
: { defaultValue })}
|
||||
{...props}
|
||||
{...controlledProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1203,43 +1127,43 @@ export const PromptInputTools = ({
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||
|
||||
export const PromptInputButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PromptInputButtonProps
|
||||
>(({ variant = "ghost", className, size, ...props }, ref) => (
|
||||
<InputGroupButton
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
PromptInputButton.displayName = "PromptInputButton";
|
||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||
|
||||
export const PromptInputButton = ({
|
||||
variant = "ghost",
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: PromptInputButtonProps) => {
|
||||
return (
|
||||
<InputGroupButton
|
||||
className={cn(className)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
||||
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
||||
<DropdownMenu {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
||||
|
||||
export const PromptInputActionMenuTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PromptInputActionMenuTriggerProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton ref={ref} className={className} {...props}>
|
||||
{children ?? <PlusIcon className="size-4" />}
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
));
|
||||
|
||||
PromptInputActionMenuTrigger.displayName = "PromptInputActionMenuTrigger";
|
||||
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
||||
|
||||
export const PromptInputActionMenuTrigger = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputActionMenuTriggerProps) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton className={className} {...props}>
|
||||
{children ?? <PlusIcon className="size-4" />}
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type PromptInputActionMenuContentProps = ComponentProps<
|
||||
typeof DropdownMenuContent
|
||||
@@ -1290,9 +1214,9 @@ export const PromptInputSubmit = ({
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
className={cn(
|
||||
"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",
|
||||
"transition-all duration-300",
|
||||
status === "streaming" &&
|
||||
"streaming-stop-button bg-primary/10 text-primary border-primary/25 hover:bg-primary/14",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ScrollText,
|
||||
Timer,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -270,22 +268,6 @@ 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" />
|
||||
@@ -605,22 +587,6 @@ 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" />
|
||||
|
||||
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,42 +71,37 @@ 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} />
|
||||
<div
|
||||
data-slot="dialog-viewport"
|
||||
className={cn("legacy-dialog-viewport", overlayClassName)}
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
"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,
|
||||
};
|
||||
"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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -97,22 +97,24 @@ const inputGroupButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const InputGroupButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
Omit<React.ComponentPropsWithoutRef<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>
|
||||
>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
InputGroupButton.displayName = "InputGroupButton";
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
|
||||
@@ -151,8 +151,8 @@
|
||||
padding: 6px;
|
||||
background: radial-gradient(
|
||||
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||
rgba(132, 0, 255, 0.8) 0%,
|
||||
rgba(132, 0, 255, 0.4) 30%,
|
||||
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
|
||||
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
border-radius: inherit;
|
||||
@@ -165,7 +165,7 @@
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: var(--glow-intensity);
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -1,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(
|
||||
"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,
|
||||
};
|
||||
"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,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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: {
|
||||
|
||||
@@ -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,7 +278,6 @@ export function AgentGuide({
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const [appDialogOpen, setAppDialogOpen] = useState(false);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
@@ -318,32 +317,7 @@ export function AgentGuide({
|
||||
setCurrent(-1);
|
||||
}, [stage]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (!eligible) 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, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, 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, setGeneratingArtifact } = useArtifacts();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const navigate = useNavigate();
|
||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||
@@ -443,14 +443,7 @@ export function ArtifactFileDetail({
|
||||
presentationId: null,
|
||||
thinking: "",
|
||||
});
|
||||
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 documentRewriteActive = documentRewrite.isActive;
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
@@ -1532,7 +1525,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,11 +7,9 @@ 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:")) {
|
||||
@@ -40,8 +38,6 @@ 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;
|
||||
@@ -90,26 +86,6 @@ 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);
|
||||
@@ -153,7 +129,6 @@ 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));
|
||||
@@ -165,12 +140,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
[canSwitchArtifact, sidebar],
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const openStreamedArtifact = useCallback(
|
||||
(artifact: string, previewKey: string) => {
|
||||
if (!canSwitchArtifact(artifact, false)) return false;
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
@@ -206,11 +180,10 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
|
||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
generatingArtifactsRef.current.clear();
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
@@ -251,7 +224,6 @@ 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, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
latestCompletedArtifactMutationKey,
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
isWriteFileInFlight,
|
||||
} from "@/core/artifacts/write-file-in-flight";
|
||||
import {
|
||||
isAutoPreviewableArtifactPath,
|
||||
@@ -31,7 +30,7 @@ import {
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { resolveRagModeRequested, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
||||
@@ -153,11 +152,11 @@ const ChatBox: React.FC<{
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled ||
|
||||
!!knowledgeReferences;
|
||||
const ragModeEnabled = resolveRagModeRequested({
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled: settings.display.ragModeEnabled,
|
||||
knowledgeSelected: 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.
|
||||
@@ -253,20 +252,8 @@ 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()),
|
||||
@@ -319,7 +306,7 @@ const ChatBox: React.FC<{
|
||||
return;
|
||||
}
|
||||
openedPreferredArtifactRef.current = preferredKey;
|
||||
selectArtifact(availablePreferredArtifact, true);
|
||||
selectArtifact(availablePreferredArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
// The same canonical report path may be overwritten by a later run while
|
||||
@@ -587,7 +574,7 @@ const ChatBox: React.FC<{
|
||||
previewKey,
|
||||
);
|
||||
} else {
|
||||
selectArtifact(automaticTarget, true);
|
||||
selectArtifact(automaticTarget);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
@@ -613,7 +600,7 @@ const ChatBox: React.FC<{
|
||||
(artifact) => !isWordDocumentFile(artifact),
|
||||
);
|
||||
if (firstPreviewableArtifact) {
|
||||
selectArtifact(firstPreviewableArtifact, true);
|
||||
selectArtifact(firstPreviewableArtifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,8 +623,8 @@ const ChatBox: React.FC<{
|
||||
writingModeEnabled,
|
||||
]);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// 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(
|
||||
@@ -688,7 +675,7 @@ const ChatBox: React.FC<{
|
||||
return;
|
||||
}
|
||||
if (!selectedArtifact) {
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||
}
|
||||
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,9 +304,7 @@ export function CompactModeGuide({
|
||||
setCurrent(-1);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
// 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;
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
|
||||
@@ -92,11 +92,7 @@ import {
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
@@ -386,14 +382,13 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const ragModeEnabled =
|
||||
referenceModeEntryEnabled && localSettings.display.ragModeEnabled;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
}
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
setLocalSettings("display", {
|
||||
ragModeEnabled: !localSettings.display.ragModeEnabled,
|
||||
});
|
||||
}, [localSettings.display.ragModeEnabled, setLocalSettings]);
|
||||
|
||||
const contextCompressionEnabled =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
@@ -415,10 +410,6 @@ 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) {
|
||||
@@ -443,11 +434,9 @@ export function InputBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -708,7 +697,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:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
|
||||
: "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",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -746,7 +735,7 @@ export function InputBox({
|
||||
>
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className="!px-2" />
|
||||
<PromptInputActionMenuTrigger className="px-2!" />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments
|
||||
label={t.inputBox.addAttachments}
|
||||
@@ -771,8 +760,8 @@ export function InputBox({
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
context.writing_mode
|
||||
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
|
||||
: compact
|
||||
@@ -800,8 +789,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
|
||||
@@ -827,11 +816,10 @@ export function InputBox({
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
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",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
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
|
||||
@@ -859,8 +847,8 @@ export function InputBox({
|
||||
aria-pressed={contextCompressionEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
contextCompressionEnabled
|
||||
? "border border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: compact
|
||||
@@ -896,8 +884,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"
|
||||
@@ -1141,7 +1129,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" &&
|
||||
@@ -1299,12 +1287,11 @@ export function InputBox({
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton
|
||||
data-firefox52-smoke="model-selector"
|
||||
className={cn(
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"rounded-lg border border-transparent bg-gray-50/60 !px-2.5 !py-1 font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col items-start text-left">
|
||||
@@ -1465,7 +1452,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,7 +54,6 @@ 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 />助手知识库
|
||||
@@ -65,7 +64,6 @@ export function KnowledgeProductNav() {
|
||||
<SearchCheck />检索调试
|
||||
</Button>
|
||||
)}
|
||||
*/}
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
@@ -73,7 +71,6 @@ 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}
|
||||
@@ -88,7 +85,6 @@ 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,
|
||||
namedStreamingWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
@@ -57,12 +57,9 @@ 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 { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
@@ -86,7 +83,6 @@ 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,
|
||||
@@ -94,17 +90,14 @@ 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",
|
||||
);
|
||||
@@ -119,13 +112,7 @@ export function MessageGroup({
|
||||
() => convertToSteps(messages, writingModeEnabled),
|
||||
[messages, writingModeEnabled],
|
||||
);
|
||||
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 { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
@@ -284,7 +271,6 @@ export function MessageGroup({
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
|
||||
<SingleRoundRagCitationDrawer
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
@@ -795,10 +781,17 @@ function ArtifactWriteToolCall({
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const sourcePath = namedStreamingWriteFilePath(rawPath);
|
||||
const showInlineContent = name === "write_file" && !sourcePath;
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
// Until a real path arrives, both auto-open and manual clicks stay inline.
|
||||
// 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.
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
@@ -839,40 +832,16 @@ function ArtifactWriteToolCall({
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{showInlineContent ? (
|
||||
content && <InlineWritePreview content={content} />
|
||||
) : (normalizedPath || sourcePath) && (
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
type: T;
|
||||
@@ -904,37 +873,14 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
};
|
||||
steps.push(step);
|
||||
}
|
||||
const writes = new Map(
|
||||
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
|
||||
);
|
||||
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
|
||||
.tool_call_chunks;
|
||||
const toolCalls = [...(message.tool_calls ?? [])];
|
||||
for (const write of writes.values()) {
|
||||
if (
|
||||
!toolCalls.some((call) => call.id === write.id) &&
|
||||
chunks?.some(
|
||||
(chunk) => chunk.id === write.id && chunk.name === "write_file",
|
||||
)
|
||||
) {
|
||||
toolCalls.push({
|
||||
id: write.id,
|
||||
name: "write_file",
|
||||
args: { path: write.path, content: write.content },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tool_call of toolCalls) {
|
||||
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
|
||||
for (const tool_call of message.tool_calls ?? []) {
|
||||
if (tool_call.name === "task") {
|
||||
continue;
|
||||
}
|
||||
const write = tool_call.name === "write_file" && tool_call.id
|
||||
? writes.get(tool_call.id)
|
||||
: undefined;
|
||||
const args = write
|
||||
? { ...tool_call.args, path: write.path, content: write.content }
|
||||
: tool_call.args;
|
||||
const writePath = typeof args?.path === "string" ? args.path : "";
|
||||
const writePath =
|
||||
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||
? (tool_call.args as { path: string }).path
|
||||
: "";
|
||||
if (
|
||||
(
|
||||
tool_call.name === "write_file" ||
|
||||
@@ -952,7 +898,7 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
messageId: message.id,
|
||||
type: "toolCall",
|
||||
name: tool_call.name,
|
||||
args,
|
||||
args: tool_call.args,
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -74,18 +74,6 @@ 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 =
|
||||
@@ -97,7 +85,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 }}>
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* 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,11 +158,7 @@ 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,
|
||||
requestFile: typeof apiFetch = apiFetch,
|
||||
) {
|
||||
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -174,10 +170,9 @@ export function useAuthedObjectUrl(
|
||||
}
|
||||
let cancelled = false;
|
||||
let created: string | null = null;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
requestFile(url, { signal: controller.signal })
|
||||
apiFetch(url)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
@@ -196,10 +191,9 @@ export function useAuthedObjectUrl(
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [url, enabled, requestFile]);
|
||||
}, [url, enabled]);
|
||||
|
||||
return { objectUrl, loading, error };
|
||||
}
|
||||
@@ -219,9 +213,6 @@ export function UploadedFilePreviewDialog({
|
||||
fileUrl,
|
||||
threadId,
|
||||
virtualPath,
|
||||
requestFile = apiFetch,
|
||||
onDownload,
|
||||
downloading = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
@@ -231,11 +222,6 @@ 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;
|
||||
}) {
|
||||
@@ -256,7 +242,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, requestFile);
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||
|
||||
// text / office → fetch text (office reads the converted sibling .md).
|
||||
const textUrls = useMemo(
|
||||
@@ -293,7 +279,6 @@ export function UploadedFilePreviewDialog({
|
||||
useEffect(() => {
|
||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setTextLoading(true);
|
||||
setTextError(null);
|
||||
setText(null);
|
||||
@@ -308,7 +293,7 @@ export function UploadedFilePreviewDialog({
|
||||
)}/artifact-preview?path=${encodeURIComponent(
|
||||
previewPath,
|
||||
)}&max_chars=${maxPreviewChars}`;
|
||||
const response = await apiFetch(endpoint, { signal: controller.signal });
|
||||
const response = await apiFetch(endpoint);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const body = (await response.json()) as ArtifactPreviewResponse;
|
||||
const rawContent = String(body.content ?? "");
|
||||
@@ -335,8 +320,7 @@ export function UploadedFilePreviewDialog({
|
||||
let lastError: Error | null = null;
|
||||
for (const textUrl of textUrls) {
|
||||
try {
|
||||
const response = await requestFile(textUrl, {
|
||||
signal: controller.signal,
|
||||
const response = await apiFetch(textUrl, {
|
||||
headers: { Range: `bytes=0-${maxBytes - 1}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -372,9 +356,8 @@ export function UploadedFilePreviewDialog({
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
|
||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
|
||||
|
||||
const renderAsMarkdown =
|
||||
kind === "office" || ext === "md" || ext === "markdown";
|
||||
@@ -393,36 +376,21 @@ export function UploadedFilePreviewDialog({
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100dvh - 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">
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -431,7 +399,7 @@ export function UploadedFilePreviewDialog({
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
@@ -445,7 +413,7 @@ export function UploadedFilePreviewDialog({
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
@@ -457,7 +425,7 @@ export function UploadedFilePreviewDialog({
|
||||
textLoading ? (
|
||||
loadingView
|
||||
) : textError ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{previewMeta &&
|
||||
@@ -495,7 +463,7 @@ export function UploadedFilePreviewDialog({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -503,34 +471,15 @@ export function UploadedFilePreviewDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
|
||||
fileUrl: string;
|
||||
label: string;
|
||||
onDownload?: () => void | Promise<void>;
|
||||
downloading?: boolean;
|
||||
}) {
|
||||
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||
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">
|
||||
<p>暂不支持在线预览,请下载原文件查看。</p>
|
||||
{onDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloading}
|
||||
onClick={() => void onDownload()}
|
||||
>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,37 +7,29 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
||||
import { getUsageMetadata } from "@/core/messages/usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
|
||||
import {
|
||||
GENERATION_IDLE_MS,
|
||||
SAMPLE_THROTTLE_MS,
|
||||
charsToTokensPerSec,
|
||||
messageHasToolCalls,
|
||||
pushSpeedSample,
|
||||
rollingTokensPerSec,
|
||||
shouldFreezeGeneration,
|
||||
usageTokensPerSec,
|
||||
type SpeedSample,
|
||||
} from "./speed-metrics";
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Per-AI-message metric: live rolling rate during that model-call segment,
|
||||
* frozen once the segment ends (not when the whole agent turn ends).
|
||||
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||
*/
|
||||
export interface MessageMetric {
|
||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||
tokensPerSec: number;
|
||||
/**
|
||||
* Wall-clock duration of this model-call segment: first visible character
|
||||
* → last visible character (tool-wait after the call is excluded).
|
||||
* Wall-clock duration from when the message first appeared with text to
|
||||
* either *now* (active) or to when the stream ended (frozen).
|
||||
*/
|
||||
durationMs: number;
|
||||
/** True while this message is still generating visible tokens. */
|
||||
/** True while this is the currently streaming message. */
|
||||
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
|
||||
@@ -53,12 +45,15 @@ export interface MessageMetric {
|
||||
isTurnFinal: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
|
||||
type TimingEntry = {
|
||||
firstSeenAt: number;
|
||||
endedAt: number | null;
|
||||
lastGrewAt: number;
|
||||
samples: SpeedSample[];
|
||||
samples: Sample[];
|
||||
lastChars: number;
|
||||
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||
hadUsage: boolean;
|
||||
};
|
||||
|
||||
function isAiMessage(message: Message): boolean {
|
||||
@@ -87,12 +82,11 @@ 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.
|
||||
* - 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.
|
||||
* - 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.
|
||||
*/
|
||||
export function useMessageMetrics(
|
||||
messages: Message[],
|
||||
@@ -110,8 +104,7 @@ export function useMessageMetrics(
|
||||
// synchronously inside an effect so React isn't tracking the map identity.
|
||||
useEffect(() => {
|
||||
const now = performance.now();
|
||||
const grewIds = new Set<string>();
|
||||
let latestId: string | null = null;
|
||||
let activeId: string | null = null;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
@@ -125,62 +118,50 @@ 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);
|
||||
}
|
||||
|
||||
latestId = id;
|
||||
// Track the most recent AI message as the active one (while streaming).
|
||||
activeId = id;
|
||||
|
||||
// Sample only when actively streaming OR when content actually grew —
|
||||
// throttle to keep arrays bounded.
|
||||
const grew = text.length !== entry.lastChars;
|
||||
if (grew) {
|
||||
grewIds.add(id);
|
||||
entry.lastGrewAt = now;
|
||||
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
pushSpeedSample(entry.samples, now, text.length);
|
||||
lastSampleAtRef.current = now;
|
||||
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);
|
||||
}
|
||||
lastSampleAtRef.current = now;
|
||||
} else if (grew) {
|
||||
// Still update lastChars so the next render captures it eventually.
|
||||
entry.lastChars = text.length;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (getUsageMetadata(message)) {
|
||||
entry.hadUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
lastActiveIdRef.current = latestId;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastActiveIdRef.current = activeId;
|
||||
setTick((v) => v + 1);
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Drive a slow tick while streaming so live durations / pause decay refresh.
|
||||
// Drive a slow tick while streaming so live durations refresh smoothly.
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||
@@ -217,36 +198,45 @@ 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 (entry.endedAt != null) {
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
|
||||
fromUsage = true;
|
||||
} else if (durationMs > 0) {
|
||||
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
|
||||
}
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||
fromUsage = true;
|
||||
} else {
|
||||
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
|
||||
isPaused =
|
||||
isActive &&
|
||||
tokensPerSec <= 0 &&
|
||||
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
} else if (durationMs > 0) {
|
||||
// Finished message without usage data: average over full duration.
|
||||
const chars = entry.lastChars;
|
||||
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
out.set(id, {
|
||||
tokensPerSec,
|
||||
durationMs,
|
||||
isActive,
|
||||
isPaused,
|
||||
fromUsage,
|
||||
isTurnFinal: turnFinalIds.has(id),
|
||||
});
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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,21 +11,68 @@ 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;
|
||||
@@ -81,7 +128,16 @@ export function QuestionNavigator({
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
|
||||
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 visible = questions.length >= MIN_QUESTIONS;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -226,9 +226,7 @@ export function SkillDetailDialog({
|
||||
style={{
|
||||
width: "min(48rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(760px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(760px, calc(100vh - 2rem))",
|
||||
height: "min(760px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
|
||||
@@ -149,9 +149,7 @@ export function SkillFilePreviewDialog({
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100dvh - 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.MapEventType["error"]) => {
|
||||
const onError = (event: maplibregl.ErrorEvent) => {
|
||||
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,7 +20,6 @@ import {
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
TimerIcon,
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -266,22 +265,6 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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 ?? [];
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -2,218 +2,149 @@
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import {
|
||||
createDeadlineSignal,
|
||||
installAbortSignalTimeout,
|
||||
} from "../compat/abort-signal";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
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 { 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 {
|
||||
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),
|
||||
// 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);
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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,10 +43,6 @@ export interface ArtifactLibraryResponse {
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ArtifactLibraryItem[];
|
||||
syncing?: boolean;
|
||||
syncStatus?: string | null;
|
||||
indexedAt?: string | null;
|
||||
syncError?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchArtifactLibrary({
|
||||
@@ -91,10 +87,6 @@ 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;
|
||||
@@ -119,10 +111,6 @@ 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,14 +70,8 @@ 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
|
||||
? 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;
|
||||
})
|
||||
? byId
|
||||
: chunks.filter((chunk) => {
|
||||
if (toolCall.name && chunk?.name && chunk.name !== toolCall.name) {
|
||||
return false;
|
||||
@@ -97,15 +91,12 @@ function matchingToolArgChunks(
|
||||
}
|
||||
|
||||
export function resolveStreamingWriteFileArgs(
|
||||
message: object,
|
||||
message: { tool_call_chunks?: readonly ToolCallChunkLike[] },
|
||||
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(chunks, toolCall)),
|
||||
accumulateToolArgChunks(matchingToolArgChunks(message.tool_call_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", () => {
|
||||
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
|
||||
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), false);
|
||||
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
|
||||
});
|
||||
@@ -39,17 +39,8 @@ export function provisionalWriteFilePath(content: string): string {
|
||||
export function isProvisionalStreamingArtifactPath(path: string): boolean {
|
||||
const pathname =
|
||||
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
|
||||
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;
|
||||
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
|
||||
}
|
||||
|
||||
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
|
||||
@@ -75,6 +66,9 @@ 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 leaves pathless streaming drafts in the timeline", () => {
|
||||
test("normal mode keeps previewing pathless streaming drafts", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
@@ -337,7 +337,8 @@ test("normal mode leaves pathless streaming drafts in the timeline", () => {
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
|
||||
@@ -465,40 +466,6 @@ 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,10 +7,11 @@ import {
|
||||
} from "./parse-partial-json";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
namedStreamingWriteFilePath,
|
||||
provisionalWriteFilePath,
|
||||
} from "./streaming-write-preview";
|
||||
|
||||
export { resolveStreamingWriteFileArgs } from "./parse-partial-json";
|
||||
@@ -249,7 +250,16 @@ export function latestInFlightWriteFilePreview(
|
||||
if (hasToolResult(thread, toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
const sourcePath = namedStreamingWriteFilePath(toolCall.path);
|
||||
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;
|
||||
if (!sourcePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
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, prefix: string, rawLabel: string, rawUrl: string) => {
|
||||
/(?<!!)\[([^\]\n]+)\]\(([^)\n]+)\)/g,
|
||||
(original, 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 `${prefix}[${rawLabel}](${buildPageUrl(knowledgeBaseId, target.slug)})`;
|
||||
return `[${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)
|
||||
: "";
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
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()];
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,14 +3,7 @@ import test from "node:test";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
groupMessages,
|
||||
hasOnlyInternalToolCalls,
|
||||
hasVisibleToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
isViewedImagesContextMessage,
|
||||
stripUploadedFilesTag,
|
||||
} from "./utils";
|
||||
import { groupMessages, isHiddenFromUIMessage, isViewedImagesContextMessage, stripUploadedFilesTag } from "./utils";
|
||||
|
||||
function groupTypes(messages: Message[]) {
|
||||
const types: string[] = [];
|
||||
@@ -220,173 +213,6 @@ 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>",
|
||||
@@ -417,8 +243,7 @@ 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],
|
||||
const types = groupMessages([legacy, { type: "ai", id: "a1", content: "ok" } as Message]).map(
|
||||
(group) => group.type,
|
||||
);
|
||||
assert.deepEqual(types, ["assistant"]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
@@ -56,7 +55,6 @@ 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).
|
||||
@@ -82,24 +80,8 @@ export function groupMessages<T>(
|
||||
if (isHiddenFromUIMessage(message)) {
|
||||
continue;
|
||||
}
|
||||
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 ?? "");
|
||||
|
||||
if (message.name === "todo_reminder") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -228,7 +210,7 @@ export function groupMessages<T>(
|
||||
type: "assistant:subagent",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (hasReasoning(message) || hasVisibleToolCalls(message)) {
|
||||
} else if (hasReasoning(message) || hasToolCalls(message)) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
// Accumulate consecutive intermediate AI messages into one processing group.
|
||||
if (lastGroup?.type !== "assistant:processing") {
|
||||
@@ -244,7 +226,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) && !hasVisibleToolCalls(message)) {
|
||||
if (hasContent(message) && !hasToolCalls(message)) {
|
||||
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||||
}
|
||||
}
|
||||
@@ -474,27 +456,6 @@ 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" &&
|
||||
@@ -672,15 +633,6 @@ 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;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
@@ -52,44 +52,10 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
};
|
||||
}
|
||||
|
||||
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() {
|
||||
const CJK_TEXT_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
export function rehypeSplitWordsIntoSpans() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, "element", ((node: Element) => {
|
||||
if (
|
||||
@@ -105,13 +71,17 @@ export function rehypeSplitWordsIntoSpans() {
|
||||
newChildren.push(child);
|
||||
return;
|
||||
}
|
||||
const words = splitTextIntoAnimationSegments(child.value);
|
||||
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);
|
||||
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
Reference in new issue
Block a user