Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1ad5a3e5 |
No files matched your search
@@ -23,8 +23,6 @@ frontend-web/.pnpm-store/
|
|||||||
frontend-web/tsconfig.tsbuildinfo
|
frontend-web/tsconfig.tsbuildinfo
|
||||||
frontend-web/vite-dev.log
|
frontend-web/vite-dev.log
|
||||||
frontend-web/vite-dev.err.log
|
frontend-web/vite-dev.err.log
|
||||||
frontend-web/.rspack-cache/
|
|
||||||
frontend-web/dist-firefox52/
|
|
||||||
|
|
||||||
# Frontend local packages/archives/docs (non-source)
|
# Frontend local packages/archives/docs (non-source)
|
||||||
frontend-web/pnpm-lock.yaml
|
frontend-web/pnpm-lock.yaml
|
||||||
@@ -98,8 +96,6 @@ offline-backend-20260512/backend/tests/*
|
|||||||
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/**
|
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/**
|
||||||
# Keep startup seed completeness and task-report conversion regressions versioned.
|
# 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_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_task_report_convert.py
|
||||||
!offline-backend-20260512/backend/tests/test_migration_compatibility.py
|
!offline-backend-20260512/backend/tests/test_migration_compatibility.py
|
||||||
# Keep project-scoped cross-conversation memory regressions versioned.
|
# Keep project-scoped cross-conversation memory regressions versioned.
|
||||||
@@ -244,6 +240,3 @@ __pycache__/
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Local Firefox 52 test profiles
|
|
||||||
.firefox52-profile*/
|
|
||||||
@@ -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`.
|
- **按钮管理 (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`
|
- **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
|
- **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)
|
- **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
|
- **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
|
- **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
|
# 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.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## 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`
|
- **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)
|
- **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.
|
- **普通问答地图可视化**: 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.
|
- **定时任务「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`.
|
- **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`.
|
- **策略优选**: 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
|
### 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.
|
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
|
## 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 错误处理和已完成的最小技能示例,
|
本说明对应本次提交的修改,包含模型流式兼容与恢复、React #185 错误处理和已完成的最小技能示例,
|
||||||
以及可选的技能视图复用、版本缓存、模型计时日志队列和 Gateway 线程池扩容。
|
以及可选的技能视图复用、版本缓存、模型计时日志队列和 Gateway 线程池扩容。
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- **后台代码也要更新**:四个新开关需要新版后台读取。只换前端 dist 或只把字段写进旧版 config.yaml 不会实现后台优化。
|
- **后台代码也要更新**:四个新开关需要新版后台读取。只换前端 dist 或只把字段写进旧版 config.yaml 不会实现后台优化。
|
||||||
- **前端**:使用本说明末尾的 ZIP,里面是完整 `dist/`,不是源码或 node_modules。
|
- **前端**:使用本说明末尾的 ZIP,里面是完整 `dist/`,不是源码或 node_modules。
|
||||||
- **仓库运行配置已开启优化**:两份 config.yaml 的四个开关均已显式设为 `true`。但已有服务器的持久化/外挂配置不会被仓库自动覆盖,仍需手工同步。
|
- **默认不启用优化**:仓库两份 config.yaml 的四个新开关均为 `false`。只有主动改为 `true` 才启用对应项。
|
||||||
- 开启线程池开关后可将上限调整为 100,但不提高模型服务商配额、不启用多 worker。
|
- 开启线程池开关后可将上限调整为 100,但不提高模型服务商配额、不启用多 worker。
|
||||||
|
|
||||||
## 2. .env 需要修改哪些项
|
## 2. .env 需要修改哪些项
|
||||||
@@ -54,9 +54,8 @@ DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE=100
|
|||||||
|
|
||||||
## 3. config.yaml 新增配置
|
## 3. config.yaml 新增配置
|
||||||
|
|
||||||
在实际加载的 `config.yaml` **顶层**配置这一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
|
在实际加载的 `config.yaml` **顶层**新增一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
|
||||||
也不要重复定义两个 `runtime_performance`。下面是程序在字段缺失时的兼容性默认值;
|
也不要重复定义两个 `runtime_performance`:
|
||||||
当前仓库的两份可运行配置已使用后文的高并发开启值:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
runtime_performance:
|
runtime_performance:
|
||||||
@@ -93,7 +92,7 @@ runtime_performance:
|
|||||||
|
|
||||||
- 已设置 `DEER_FLOW_CONFIG_PATH` 时,以它指向的文件为准;Docker 内该值必须是容器内可访问路径。
|
- 已设置 `DEER_FLOW_CONFIG_PATH` 时,以它指向的文件为准;Docker 内该值必须是容器内可访问路径。
|
||||||
- 本仓库包含 `offline-backend-20260512/config.yaml` 和 `offline-backend-20260512/backend/config.yaml`;
|
- 本仓库包含 `offline-backend-20260512/config.yaml` 和 `offline-backend-20260512/backend/config.yaml`;
|
||||||
两份已同步为高并发开启值,但服务器要修改的仍是启动时实际加载/挂载的那一份。
|
两份都已补齐默认关闭的示例,但服务器要修改的是启动时实际加载/挂载的那一份。
|
||||||
- 启动日志 `Loaded AppConfig from ...` 可确认路径,`RUNTIME_PERFORMANCE` 可确认实际开关值。
|
- 启动日志 `Loaded AppConfig from ...` 可确认路径,`RUNTIME_PERFORMANCE` 可确认实际开关值。
|
||||||
|
|
||||||
## 4. 开启后做了什么,没做什么
|
## 4. 开启后做了什么,没做什么
|
||||||
@@ -113,9 +112,6 @@ runtime_performance:
|
|||||||
没有采用会延迟下线的 TTL 缓存;下一次工具调用能发现下线或无效配置并拒绝访问。
|
没有采用会延迟下线的 TTL 缓存;下一次工具调用能发现下线或无效配置并拒绝访问。
|
||||||
已经开始执行的工具不因此被强行中断。
|
已经开始执行的工具不因此被强行中断。
|
||||||
缓存仅用于标准 `LocalSkillStorage`,自定义存储实现/子类保守回到原刷新路径。
|
缓存仅用于标准 `LocalSkillStorage`,自定义存储实现/子类保守回到原刷新路径。
|
||||||
同一 Gateway 进程内,仅在多个热命中调用真正重叠时合并为一次元数据校验;
|
|
||||||
该批调用结束后结果立即丢弃,新调用仍重新校验。启停文件哈希不同的调用不会加入旧校验,
|
|
||||||
校验异常会对该批调用统一 fail-closed,且不会阻止后续调用重试。
|
|
||||||
|
|
||||||
热路径仍有 O(文件数) 元数据校验,慢磁盘/网络挂载仍可能耗时;这不是“所有文件操作零开销”,
|
热路径仍有 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` | 启动开发服务器(默认端口 5173;项目实际通过仓库根脚本统一为 5174) |
|
||||||
| `pnpm dev:vite` / `npm run dev:vite` | `vite` | 仅在现代浏览器调试时启动原 Vite 开发服务器 |
|
|
||||||
| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) |
|
| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) |
|
||||||
| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 |
|
| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 |
|
||||||
| `pnpm typecheck` | `tsc --noEmit` | 仅做 TS 类型检查,不输出文件 |
|
| `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 基线(迁移零新增)
|
||||||
|
```
|
||||||
@@ -12,40 +12,6 @@
|
|||||||
root.classList.add("dark");
|
root.classList.add("dark");
|
||||||
} catch (e) {}
|
} 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>
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|||||||
Generated
+554
-5062
File diff suppressed because it is too large.
Load diff
+10
-49
@@ -4,40 +4,20 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rspack serve --config rspack.firefox52.config.mjs --mode development",
|
"dev": "vite",
|
||||||
"dev:vite": "vite",
|
|
||||||
"dev:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode development",
|
|
||||||
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
"build": "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: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: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",
|
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:firefox52": "rspack serve --config rspack.firefox52.config.mjs --mode production --env preview=true",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
"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: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: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: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: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",
|
"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-css": "node scripts/check-legacy-css.mjs"
|
||||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
|
|
||||||
"check:firefox52-css": "node scripts/check-firefox52-css.mjs",
|
|
||||||
"check:firefox52-js": "node scripts/check-firefox52-js.mjs",
|
|
||||||
"test:firefox52-build": "node --test build/firefox52/legacy-generator-loader.test.cjs build/firefox52/source-compat-loader.test.cjs",
|
|
||||||
"check:firefox52": "pnpm test:firefox52-build && pnpm test:firefox52-markdown && pnpm check:firefox52-css && pnpm check:firefox52-js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -91,35 +71,32 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.45.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"dnd-kit": "^0.0.2",
|
"dnd-kit": "^0.0.2",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"maplibre-gl": "4.7.1",
|
"maplibre-gl": "^6.9.0",
|
||||||
"marked": "16.4.2",
|
"marked": "16.4.2",
|
||||||
"mermaid": "^11.15.0",
|
"mermaid": "^11.15.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"ogl": "^1.0.11",
|
"ogl": "^1.0.11",
|
||||||
"pepjs": "0.5.3",
|
"react": "^19.0.0",
|
||||||
"react": "^18.3.1",
|
"react-dom": "^19.0.0",
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"regenerator-runtime": "0.14.1",
|
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@@ -137,42 +114,26 @@
|
|||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/file-saver": "^2.0.7",
|
||||||
"@types/gsap": "^3.0.0",
|
"@types/gsap": "^3.0.0",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-legacy": "6.1.1",
|
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"abortcontroller-polyfill": "^1.7.8",
|
|
||||||
"acorn": "8.19.0",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-loader": "10.0.0",
|
|
||||||
"intersection-observer": "^0.12.2",
|
|
||||||
"lightningcss": "^1.30.2",
|
"lightningcss": "^1.30.2",
|
||||||
"postcss": "^8.5.3",
|
"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": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"terser": "^5.51.2",
|
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vite-plugin-source-identifier": "^1.1.2",
|
"vite-plugin-source-identifier": "^1.1.2"
|
||||||
"web-streams-polyfill": "4.3.0"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@radix-ui/react-slot": "1.3.3"
|
"@radix-ui/react-slot": "1.3.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.26.2",
|
|
||||||
"browserslist": [
|
"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 darkDescendant from "./scripts/postcss-dark-descendant.mjs";
|
||||||
import autoprefixer from "autoprefixer";
|
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 {
|
export default {
|
||||||
plugins: [tailwindcss, darkDescendant, autoprefixer],
|
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 path from "node:path";
|
||||||
import postcss from "postcss";
|
import postcss from "postcss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Firefox 52 CSS 产物守卫:构建后运行,防止回归。
|
* 旧浏览器(麒麟 Chromium ~80)CSS 产物守卫:构建后运行,防止回归。
|
||||||
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
|
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
|
||||||
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
|
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
|
||||||
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式;
|
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式,全库 :is( 为 0;
|
||||||
* - :where() 由 Firefox 专用 PostCSS 插件展开;
|
|
||||||
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
|
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
|
||||||
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
|
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
|
||||||
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
|
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
|
||||||
* :has() 按渐进增强放行,关键输入框布局由显式 class 和 :focus-within 兜底。
|
* :has()(Chrome 105+)与 :where()(Chrome 88+)按渐进增强放行,不在检查范围。
|
||||||
*/
|
*/
|
||||||
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
|
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)) {
|
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"));
|
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 failures = [];
|
||||||
const hardTokens = [
|
const hardTokens = [
|
||||||
[/@layer\b/, "@layer(Chrome 99+)"],
|
[/@layer\b/, "@layer(Chrome 99+)"],
|
||||||
[/@property\b/, "@property(Chrome 85+)"],
|
[/@property\b/, "@property(Chrome 85+)"],
|
||||||
[/:is\(/, ":is()(Chrome 88+)"],
|
[/:is\(/, ":is()(Chrome 88+)"],
|
||||||
[/:where\(/, ":where()(Firefox 78+)"],
|
|
||||||
[/\(width\s*>=|\(width\s*<=|\(height\s*>=/, "媒体查询 range 语法(Chrome 104+)"],
|
[/\(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 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) {
|
for (const file of cssFiles) {
|
||||||
const raw = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
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")) || [];
|
const hits = raw.match(new RegExp(re.source, "g")) || [];
|
||||||
if (hits.length > 0) failures.push(`${file}: ${hits.length} 处 ${label}`);
|
if (hits.length > 0) failures.push(`${file}: ${hits.length} 处 ${label}`);
|
||||||
}
|
}
|
||||||
progressiveHas += (raw.match(/:has\(/g) || []).length;
|
|
||||||
|
|
||||||
const root = postcss.parse(raw, { from: file });
|
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) => {
|
root.walkDecls((decl) => {
|
||||||
if (!unsupportedColor.test(decl.value)) return;
|
if (!unsupportedColor.test(decl.value)) return;
|
||||||
guardedColors += 1;
|
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) {
|
if (failures.length > 0) {
|
||||||
console.error("Firefox 52 CSS 兼容检查失败:\n");
|
console.error("旧浏览器 CSS 兼容检查失败:\n");
|
||||||
console.error(failures.slice(0, 50).join("\n"));
|
console.error(failures.slice(0, 50).join("\n"));
|
||||||
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Firefox 52 CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明,${progressiveHas} 处渐进式 :has())。`,
|
`旧浏览器(麒麟 Chromium ~80)CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明)。`,
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
|
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
|
||||||
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
|
* `.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="relative size-full">
|
||||||
<div
|
<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."
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
<div
|
<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."
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
|||||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||||
<ControlsPrimitive
|
<ControlsPrimitive
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card gap-px overflow-hidden rounded-md border p-1 !shadow-none",
|
"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",
|
"[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
|||||||
|
|
||||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||||
<CardHeader
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -65,7 +65,7 @@ export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
|||||||
|
|
||||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||||
<CardFooter
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
useUploadPolicy,
|
useUploadPolicy,
|
||||||
validateChatUploadFiles,
|
validateChatUploadFiles,
|
||||||
} from "@/core/uploads";
|
} from "@/core/uploads";
|
||||||
import { imeEnterAction } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
@@ -62,11 +62,9 @@ import {
|
|||||||
Children,
|
Children,
|
||||||
type ClipboardEventHandler,
|
type ClipboardEventHandler,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type CompositionEventHandler,
|
|
||||||
createContext,
|
createContext,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type FormEventHandler,
|
type FormEventHandler,
|
||||||
forwardRef,
|
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
type KeyboardEventHandler,
|
type KeyboardEventHandler,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
@@ -829,17 +827,11 @@ export const PromptInput = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = event.currentTarget;
|
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
|
const text = usingProvider
|
||||||
? (liveText ?? controller.textInput.value)
|
? controller.textInput.value
|
||||||
: (() => {
|
: (() => {
|
||||||
const formData = new FormData(form);
|
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
|
// Reset form immediately after capturing text to avoid race condition
|
||||||
@@ -917,10 +909,10 @@ export const PromptInput = ({
|
|||||||
ref={formRef}
|
ref={formRef}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
|
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
|
||||||
before 105 ignore them, keep the horizontal flex layout, and
|
versions before 105 ignore :has(), retain h-9, and clip the whole
|
||||||
squeeze the textarea beside the footer controls. */}
|
multi-line composer to a single row. */}
|
||||||
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
|
<InputGroup className="prompt-input-group h-auto min-h-16 flex-col items-stretch">
|
||||||
{children}
|
{children}
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</form>
|
</form>
|
||||||
@@ -942,7 +934,11 @@ export const PromptInputBody = ({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: PromptInputBodyProps) => (
|
}: PromptInputBodyProps) => (
|
||||||
<div className={cn("contents", className)} {...props} />
|
<div
|
||||||
|
data-slot="prompt-input-body"
|
||||||
|
className={cn("contents", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export type PromptInputTextareaProps = ComponentProps<
|
export type PromptInputTextareaProps = ComponentProps<
|
||||||
@@ -952,43 +948,17 @@ export type PromptInputTextareaProps = ComponentProps<
|
|||||||
export const PromptInputTextarea = ({
|
export const PromptInputTextarea = ({
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
onKeyDown,
|
|
||||||
onCompositionStart,
|
|
||||||
onCompositionEnd,
|
|
||||||
className,
|
className,
|
||||||
placeholder = "What would you like to know?",
|
placeholder = "What would you like to know?",
|
||||||
defaultValue,
|
|
||||||
...props
|
...props
|
||||||
}: PromptInputTextareaProps) => {
|
}: PromptInputTextareaProps) => {
|
||||||
const controller = useOptionalPromptInputController();
|
const controller = useOptionalPromptInputController();
|
||||||
const attachments = usePromptInputAttachments();
|
const attachments = usePromptInputAttachments();
|
||||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
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) => {
|
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 (
|
if (
|
||||||
typeof CSS !== "undefined" &&
|
typeof CSS !== "undefined" &&
|
||||||
typeof CSS.supports === "function" &&
|
typeof CSS.supports === "function" &&
|
||||||
@@ -1009,7 +979,6 @@ export const PromptInputTextarea = ({
|
|||||||
// onInput does not run for a controlled value filled by a suggested question
|
// 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.
|
// or restored draft. Resize once after every render in legacy Chrome too.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (composingRef.current) return;
|
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
@@ -1021,28 +990,13 @@ export const PromptInputTextarea = ({
|
|||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const action = imeEnterAction({
|
if (isIMEComposing(e, isComposing)) {
|
||||||
isComposing: composingRef.current,
|
|
||||||
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
|
|
||||||
keyCode: e.keyCode,
|
|
||||||
compositionEndedAt: compositionEndedAtRef.current,
|
|
||||||
now: performance.now(),
|
|
||||||
});
|
|
||||||
// Firefox 102 confirms the candidate on this key. preventDefault here
|
|
||||||
// cancels that confirmation and leaves the pinyin in the box.
|
|
||||||
if (action === "ignore") {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onKeyDownRef.current?.(e);
|
if (e.shiftKey) {
|
||||||
if (e.defaultPrevented || e.shiftKey) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The confirming Enter already finished the candidate. Swallow it so
|
|
||||||
// Firefox does not also insert a newline or send the message.
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (action === "swallow") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the submit button is disabled before submitting
|
// Check if the submit button is disabled before submitting
|
||||||
const form = e.currentTarget.form;
|
const form = e.currentTarget.form;
|
||||||
@@ -1054,59 +1008,22 @@ export const PromptInputTextarea = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
form?.requestSubmit();
|
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
|
// Remove last attachment when Backspace is pressed and textarea is empty
|
||||||
if (
|
if (
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
e.currentTarget.value === "" &&
|
e.currentTarget.value === "" &&
|
||||||
attachmentsRef.current.files.length > 0
|
attachments.files.length > 0
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lastAttachment = attachmentsRef.current.files.at(-1);
|
const lastAttachment = attachments.files.at(-1);
|
||||||
if (lastAttachment) {
|
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 handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||||
const items = event.clipboardData?.items;
|
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 (
|
return (
|
||||||
<InputGroupTextarea
|
<InputGroupTextarea
|
||||||
{...props}
|
|
||||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||||
name="message"
|
name="message"
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={3}
|
rows={3}
|
||||||
onChange={handleChange}
|
onCompositionEnd={() => setIsComposing(false)}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
onCompositionStart={handleCompositionStart}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
if (!composingRef.current) {
|
resizeForLegacyChrome(event.currentTarget);
|
||||||
resizeForLegacyChrome(event.currentTarget);
|
onInput?.(event);
|
||||||
}
|
|
||||||
onInputRef.current?.(event);
|
|
||||||
}}
|
}}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
{...(controller
|
{...props}
|
||||||
? { value: controller.textInput.value }
|
{...controlledProps}
|
||||||
: { defaultValue })}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1205,21 +1129,22 @@ export const PromptInputTools = ({
|
|||||||
|
|
||||||
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||||
|
|
||||||
export const PromptInputButton = forwardRef<
|
export const PromptInputButton = ({
|
||||||
HTMLButtonElement,
|
variant = "ghost",
|
||||||
PromptInputButtonProps
|
className,
|
||||||
>(({ variant = "ghost", className, size, ...props }, ref) => (
|
size,
|
||||||
<InputGroupButton
|
...props
|
||||||
ref={ref}
|
}: PromptInputButtonProps) => {
|
||||||
className={cn(className)}
|
return (
|
||||||
size="sm"
|
<InputGroupButton
|
||||||
type="button"
|
className={cn(className)}
|
||||||
variant={variant}
|
size="sm"
|
||||||
{...props}
|
type="button"
|
||||||
/>
|
variant={variant}
|
||||||
));
|
{...props}
|
||||||
|
/>
|
||||||
PromptInputButton.displayName = "PromptInputButton";
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
||||||
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
||||||
@@ -1228,18 +1153,17 @@ export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
|||||||
|
|
||||||
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
||||||
|
|
||||||
export const PromptInputActionMenuTrigger = forwardRef<
|
export const PromptInputActionMenuTrigger = ({
|
||||||
HTMLButtonElement,
|
className,
|
||||||
PromptInputActionMenuTriggerProps
|
children,
|
||||||
>(({ className, children, ...props }, ref) => (
|
...props
|
||||||
|
}: PromptInputActionMenuTriggerProps) => (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<PromptInputButton ref={ref} className={className} {...props}>
|
<PromptInputButton className={className} {...props}>
|
||||||
{children ?? <PlusIcon className="size-4" />}
|
{children ?? <PlusIcon className="size-4" />}
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
));
|
);
|
||||||
|
|
||||||
PromptInputActionMenuTrigger.displayName = "PromptInputActionMenuTrigger";
|
|
||||||
|
|
||||||
export type PromptInputActionMenuContentProps = ComponentProps<
|
export type PromptInputActionMenuContentProps = ComponentProps<
|
||||||
typeof DropdownMenuContent
|
typeof DropdownMenuContent
|
||||||
@@ -1292,7 +1216,7 @@ export const PromptInputSubmit = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300",
|
"transition-all duration-300",
|
||||||
status === "streaming" &&
|
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",
|
"streaming-stop-button bg-primary/10 text-primary border-primary/25 hover:bg-primary/14",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
size={size}
|
size={size}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ScrollText,
|
|
||||||
Timer,
|
|
||||||
User,
|
User,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -270,22 +268,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
<Cpu className="size-4" />
|
<Cpu className="size-4" />
|
||||||
@@ -605,22 +587,6 @@ function LegacyAccordionSidebar({
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
<Cpu className="size-4" />
|
<Cpu className="size-4" />
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -82,31 +82,26 @@ const DialogContent = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay className={overlayClassName} />
|
<DialogOverlay className={overlayClassName} />
|
||||||
<div
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-viewport"
|
ref={ref}
|
||||||
className={cn("legacy-dialog-viewport", overlayClassName)}
|
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
|
{children}
|
||||||
ref={ref}
|
{showCloseButton && (
|
||||||
data-slot="dialog-content"
|
<DialogPrimitive.Close
|
||||||
className={cn(
|
data-slot="dialog-close"
|
||||||
"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="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"
|
||||||
className,
|
>
|
||||||
)}
|
<XIcon />
|
||||||
{...props}
|
<span className="sr-only">Close</span>
|
||||||
>
|
</DialogPrimitive.Close>
|
||||||
{children}
|
)}
|
||||||
{showCloseButton && (
|
</DialogPrimitive.Content>
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</div>
|
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -240,7 +240,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -97,22 +97,24 @@ const inputGroupButtonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const InputGroupButton = React.forwardRef<
|
function InputGroupButton({
|
||||||
HTMLButtonElement,
|
className,
|
||||||
Omit<React.ComponentPropsWithoutRef<typeof Button>, "size"> &
|
type = "button",
|
||||||
VariantProps<typeof inputGroupButtonVariants>
|
variant = "ghost",
|
||||||
>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => (
|
size = "xs",
|
||||||
<Button
|
...props
|
||||||
ref={ref}
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
type={type}
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
data-size={size}
|
return (
|
||||||
variant={variant}
|
<Button
|
||||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
type={type}
|
||||||
{...props}
|
data-size={size}
|
||||||
/>
|
variant={variant}
|
||||||
));
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
InputGroupButton.displayName = "InputGroupButton";
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -151,8 +151,8 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||||
rgba(132, 0, 255, 0.8) 0%,
|
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%,
|
||||||
rgba(132, 0, 255, 0.4) 30%,
|
rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%,
|
||||||
transparent 60%
|
transparent 60%
|
||||||
);
|
);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
linear-gradient(#fff 0 0);
|
linear-gradient(#fff 0 0);
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: var(--glow-intensity);
|
opacity: 1;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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]",
|
"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" &&
|
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",
|
"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,
|
className,
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,box-shadow,transform] duration-200 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:!size-10 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-2xl group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:data-[active=true]:bg-[#fff1e8] group-data-[collapsible=icon]:data-[active=true]:text-primary group-data-[collapsible=icon]:data-[active=true]:shadow-[inset_0_0_0_1px_rgba(217,33,33,0.12),0_10px_24px_-18px_rgba(217,33,33,0.42)] group-data-[collapsible=icon]:[&>span]:hidden group-data-[collapsible=icon]:[&>svg]:mx-auto group-data-[collapsible=icon]:[&>svg]:size-5 [&>span:last-child]:truncate [&>svg]:size-[18px] [&>svg]:shrink-0",
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,box-shadow,transform] duration-200 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-2xl group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:data-[active=true]:bg-[#fff1e8] group-data-[collapsible=icon]:data-[active=true]:text-primary group-data-[collapsible=icon]:data-[active=true]:shadow-[inset_0_0_0_1px_rgba(217,33,33,0.12),0_10px_24px_-18px_rgba(217,33,33,0.42)] group-data-[collapsible=icon]:[&>span]:hidden group-data-[collapsible=icon]:[&>svg]:mx-auto group-data-[collapsible=icon]:[&>svg]:size-5 [&>span:last-child]:truncate [&>svg]:size-[18px] [&>svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -497,7 +497,7 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
size: {
|
size: {
|
||||||
default: "h-8 text-sm",
|
default: "h-8 text-sm",
|
||||||
sm: "h-7 text-xs",
|
sm: "h-7 text-xs",
|
||||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ const tabsListVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
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:
|
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: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function MarkdownCodeEditor({
|
|||||||
highlightActiveLineGutter: false,
|
highlightActiveLineGutter: false,
|
||||||
lineNumbers: true,
|
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>
|
||||||
<section className="flex min-h-0 flex-col bg-muted/10">
|
<section className="flex min-h-0 flex-col bg-muted/10">
|
||||||
|
|||||||
@@ -278,7 +278,6 @@ export function AgentGuide({
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [current, setCurrent] = useState(-1);
|
const [current, setCurrent] = useState(-1);
|
||||||
const [appDialogOpen, setAppDialogOpen] = useState(false);
|
|
||||||
const stepsRef = useRef<GuideStep[]>([]);
|
const stepsRef = useRef<GuideStep[]>([]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
@@ -318,32 +317,7 @@ export function AgentGuide({
|
|||||||
setCurrent(-1);
|
setCurrent(-1);
|
||||||
}, [stage]);
|
}, [stage]);
|
||||||
|
|
||||||
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
|
if (!eligible) return null;
|
||||||
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
|
|
||||||
// 先卸掉引导层,关掉后再从当前步恢复。
|
|
||||||
useEffect(() => {
|
|
||||||
if (!eligible) return;
|
|
||||||
const sync = () => {
|
|
||||||
setAppDialogOpen(
|
|
||||||
Boolean(document.querySelector('[data-slot="dialog-content"]')),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
sync();
|
|
||||||
const observer = new MutationObserver(sync);
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [eligible]);
|
|
||||||
|
|
||||||
// TDesign 1.18 still handles resize when current=-1 and dereferences an
|
|
||||||
// undefined step. Keep its listener mounted only during an active guide.
|
|
||||||
if (
|
|
||||||
!eligible ||
|
|
||||||
appDialogOpen ||
|
|
||||||
current < 0 ||
|
|
||||||
current >= stepsRef.current.length
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Guide
|
<Guide
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ export function AgentSelector({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"!gap-1.5 rounded-full border !px-3 shadow-none transition-colors",
|
"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 && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact
|
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"
|
? "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
|
: selectedAgent
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { defaultUrlTransform } from "react-markdown";
|
import { defaultUrlTransform } from "react-markdown";
|
||||||
@@ -288,7 +288,7 @@ export function ArtifactFileDetail({
|
|||||||
headerLeadingActionBare?: boolean;
|
headerLeadingActionBare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
const { isMock, thread, publicShareCode } = useThread();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||||
@@ -444,13 +444,6 @@ export function ArtifactFileDetail({
|
|||||||
thinking: "",
|
thinking: "",
|
||||||
});
|
});
|
||||||
const documentRewriteActive = documentRewrite.isActive;
|
const documentRewriteActive = documentRewrite.isActive;
|
||||||
const generationLockId = useId();
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const owner = `preview:${generationLockId}`;
|
|
||||||
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
|
|
||||||
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
|
|
||||||
return () => setGeneratingArtifact(owner, null);
|
|
||||||
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
|
|
||||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||||
const displayContent = documentRewriteActive
|
const displayContent = documentRewriteActive
|
||||||
@@ -1532,7 +1525,7 @@ export function ArtifactFileDetail({
|
|||||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||||
) : hasMultipleArtifacts ? (
|
) : hasMultipleArtifacts ? (
|
||||||
<Select value={filepath} onValueChange={select}>
|
<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" />
|
<SelectValue placeholder="Select a file" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="select-none">
|
<SelectContent className="select-none">
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
|
||||||
|
|
||||||
export function artifactPreviewKey(artifact: string): string {
|
export function artifactPreviewKey(artifact: string): string {
|
||||||
if (!artifact.startsWith("write-file:")) {
|
if (!artifact.startsWith("write-file:")) {
|
||||||
@@ -40,8 +38,6 @@ export interface ArtifactsContextType {
|
|||||||
select: (artifact: string, autoSelect?: boolean) => void;
|
select: (artifact: string, autoSelect?: boolean) => void;
|
||||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
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;
|
deselect: () => void;
|
||||||
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -90,26 +86,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
const [preOpen, setPreOpen] = useState(false);
|
const [preOpen, setPreOpen] = useState(false);
|
||||||
const [autoOpen, setAutoOpen] = useState(true);
|
const [autoOpen, setAutoOpen] = useState(true);
|
||||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
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,避免每次
|
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||||
@@ -153,7 +129,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(artifact: string, autoSelect = false) => {
|
(artifact: string, autoSelect = false) => {
|
||||||
if (!canSwitchArtifact(artifact, !autoSelect)) return;
|
|
||||||
const previewKey = artifactPreviewKey(artifact);
|
const previewKey = artifactPreviewKey(artifact);
|
||||||
selectedArtifactKeyRef.current = previewKey;
|
selectedArtifactKeyRef.current = previewKey;
|
||||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||||
@@ -165,12 +140,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
setAutoSelect((previous) => (previous ? false : previous));
|
setAutoSelect((previous) => (previous ? false : previous));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canSwitchArtifact, sidebar],
|
[sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openStreamedArtifact = useCallback(
|
const openStreamedArtifact = useCallback(
|
||||||
(artifact: string, previewKey: string) => {
|
(artifact: string, previewKey: string) => {
|
||||||
if (!canSwitchArtifact(artifact, false)) return false;
|
|
||||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -206,11 +180,10 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
|
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deselect = useCallback(() => {
|
const deselect = useCallback(() => {
|
||||||
generatingArtifactsRef.current.clear();
|
|
||||||
selectedArtifactKeyRef.current = null;
|
selectedArtifactKeyRef.current = null;
|
||||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||||
@@ -251,7 +224,6 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
select,
|
select,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
setGeneratingArtifact,
|
|
||||||
deselect,
|
deselect,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
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 type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
latestCompletedArtifactMutationKey,
|
latestCompletedArtifactMutationKey,
|
||||||
latestInFlightWriteFilePreview,
|
latestInFlightWriteFilePreview,
|
||||||
latestPreparedHtmlArtifact,
|
latestPreparedHtmlArtifact,
|
||||||
isWriteFileInFlight,
|
|
||||||
} from "@/core/artifacts/write-file-in-flight";
|
} from "@/core/artifacts/write-file-in-flight";
|
||||||
import {
|
import {
|
||||||
isAutoPreviewableArtifactPath,
|
isAutoPreviewableArtifactPath,
|
||||||
@@ -31,7 +30,7 @@ import {
|
|||||||
} from "@/core/artifacts/streaming-write-preview";
|
} from "@/core/artifacts/streaming-write-preview";
|
||||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
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 { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
||||||
@@ -153,11 +152,11 @@ const ChatBox: React.FC<{
|
|||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const referenceModeEntryEnabled =
|
const referenceModeEntryEnabled =
|
||||||
citationDisplay?.reference_mode_enabled ?? false;
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled = resolveRagModeRequested({
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
referenceModeEntryEnabled,
|
||||||
isThreadRagModeLocked(threadId) ||
|
ragModeEnabled: settings.display.ragModeEnabled,
|
||||||
knowledgeReferencesEnabled ||
|
knowledgeSelected: knowledgeReferencesEnabled || !!knowledgeReferences,
|
||||||
!!knowledgeReferences;
|
});
|
||||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
// the existing reference panel, but must not request or write reference
|
// the existing reference panel, but must not request or write reference
|
||||||
// batches through DeerFlow's backend APIs.
|
// batches through DeerFlow's backend APIs.
|
||||||
@@ -253,20 +252,8 @@ const ChatBox: React.FC<{
|
|||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
setGeneratingArtifact,
|
|
||||||
} = useArtifacts();
|
} = 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 [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
@@ -319,7 +306,7 @@ const ChatBox: React.FC<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openedPreferredArtifactRef.current = preferredKey;
|
openedPreferredArtifactRef.current = preferredKey;
|
||||||
selectArtifact(availablePreferredArtifact, true);
|
selectArtifact(availablePreferredArtifact);
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
// The same canonical report path may be overwritten by a later run while
|
// The same canonical report path may be overwritten by a later run while
|
||||||
@@ -587,7 +574,7 @@ const ChatBox: React.FC<{
|
|||||||
previewKey,
|
previewKey,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
selectArtifact(automaticTarget, true);
|
selectArtifact(automaticTarget);
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
}
|
}
|
||||||
@@ -613,7 +600,7 @@ const ChatBox: React.FC<{
|
|||||||
(artifact) => !isWordDocumentFile(artifact),
|
(artifact) => !isWordDocumentFile(artifact),
|
||||||
);
|
);
|
||||||
if (firstPreviewableArtifact) {
|
if (firstPreviewableArtifact) {
|
||||||
selectArtifact(firstPreviewableArtifact, true);
|
selectArtifact(firstPreviewableArtifact);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,8 +623,8 @@ const ChatBox: React.FC<{
|
|||||||
writingModeEnabled,
|
writingModeEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Live sandbox: open when an in-flight write_file has a usable output path.
|
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||||
// Pathless content stays inline in the CoT, including content-first streams.
|
// 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
|
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||||
const streamingWritePreview = useMemo(
|
const streamingWritePreview = useMemo(
|
||||||
@@ -688,7 +675,7 @@ const ChatBox: React.FC<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedArtifact) {
|
if (!selectedArtifact) {
|
||||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
|
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||||
}
|
}
|
||||||
setArtifactsOpen(true);
|
setArtifactsOpen(true);
|
||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export const CodeEditor = memo(function CodeEditor({
|
|||||||
{isStreaming && streamingReadonlyFallback ? (
|
{isStreaming && streamingReadonlyFallback ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:!outline-none",
|
"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",
|
"resize-none p-4! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
|
||||||
"border-none",
|
"border-none",
|
||||||
)}
|
)}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -95,8 +95,8 @@ export const CodeEditor = memo(function CodeEditor({
|
|||||||
readOnly={readonly ?? disabled}
|
readOnly={readonly ?? disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:!outline-none",
|
"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",
|
"px-2 py-0! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
|
||||||
)}
|
)}
|
||||||
theme={resolvedTheme === "dark" ? customDarkTheme : customLightTheme}
|
theme={resolvedTheme === "dark" ? customDarkTheme : customLightTheme}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
|
|||||||
@@ -304,9 +304,7 @@ export function CompactModeGuide({
|
|||||||
setCurrent(-1);
|
setCurrent(-1);
|
||||||
}, [clearPollTimer, releasePromptDialog]);
|
}, [clearPollTimer, releasePromptDialog]);
|
||||||
|
|
||||||
// TDesign 1.18 still handles resize when current=-1 and dereferences an
|
if (!eligible) return null;
|
||||||
// undefined step. Keep its listener mounted only during an active guide.
|
|
||||||
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Guide
|
<Guide
|
||||||
|
|||||||
@@ -92,11 +92,7 @@ import {
|
|||||||
|
|
||||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||||
import { useUserPreferences } from "@/core/user-preferences";
|
import { useUserPreferences } from "@/core/user-preferences";
|
||||||
import {
|
import { useLocalSettings } from "@/core/settings";
|
||||||
isThreadRagModeLocked,
|
|
||||||
lockThreadRagMode,
|
|
||||||
useLocalSettings,
|
|
||||||
} from "@/core/settings";
|
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||||
@@ -386,14 +382,13 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
const ragModeEnabled =
|
||||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
referenceModeEntryEnabled && localSettings.display.ragModeEnabled;
|
||||||
const handleRagModeToggle = useCallback(() => {
|
const handleRagModeToggle = useCallback(() => {
|
||||||
if (ragModeLocked) {
|
setLocalSettings("display", {
|
||||||
return;
|
ragModeEnabled: !localSettings.display.ragModeEnabled,
|
||||||
}
|
});
|
||||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
}, [localSettings.display.ragModeEnabled, setLocalSettings]);
|
||||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
|
||||||
|
|
||||||
const contextCompressionEnabled =
|
const contextCompressionEnabled =
|
||||||
localSettings.display.contextCompressionEnabled ?? false;
|
localSettings.display.contextCompressionEnabled ?? false;
|
||||||
@@ -415,10 +410,6 @@ export function InputBox({
|
|||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
setFollowupsHidden(false);
|
setFollowupsHidden(false);
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
if (ragModeEnabled) {
|
|
||||||
lockThreadRagMode(threadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against submitting before the initial model auto-selection
|
// Guard against submitting before the initial model auto-selection
|
||||||
// effect has flushed thread settings to storage/state.
|
// effect has flushed thread settings to storage/state.
|
||||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||||
@@ -443,11 +434,9 @@ export function InputBox({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
ragModeEnabled,
|
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
selectedModel?.execution_profile,
|
selectedModel?.execution_profile,
|
||||||
status,
|
status,
|
||||||
threadId,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -708,7 +697,7 @@ export function InputBox({
|
|||||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||||
isNewThread
|
isNewThread
|
||||||
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
|
? "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,
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -746,7 +735,7 @@ export function InputBox({
|
|||||||
>
|
>
|
||||||
{/* TODO: Add more connectors here
|
{/* TODO: Add more connectors here
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuTrigger className="!px-2" />
|
<PromptInputActionMenuTrigger className="px-2!" />
|
||||||
<PromptInputActionMenuContent>
|
<PromptInputActionMenuContent>
|
||||||
<PromptInputActionAddAttachments
|
<PromptInputActionAddAttachments
|
||||||
label={t.inputBox.addAttachments}
|
label={t.inputBox.addAttachments}
|
||||||
@@ -771,8 +760,8 @@ export function InputBox({
|
|||||||
>
|
>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn(
|
className={cn(
|
||||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
context.writing_mode
|
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"
|
? "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
|
: compact
|
||||||
@@ -800,8 +789,8 @@ export function InputBox({
|
|||||||
aria-pressed={artifactDirectoryModeEnabled}
|
aria-pressed={artifactDirectoryModeEnabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
artifactDirectoryModeEnabled
|
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"
|
? "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
|
: compact
|
||||||
@@ -827,11 +816,10 @@ export function InputBox({
|
|||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={ragModeEnabled}
|
aria-pressed={ragModeEnabled}
|
||||||
disabled={ragModeLocked || disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
|
||||||
ragModeEnabled
|
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"
|
? "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
|
: compact
|
||||||
@@ -859,8 +847,8 @@ export function InputBox({
|
|||||||
aria-pressed={contextCompressionEnabled}
|
aria-pressed={contextCompressionEnabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"!gap-1.5 rounded-full !px-3 shadow-none",
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
compact && "rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
contextCompressionEnabled
|
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"
|
? "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
|
: compact
|
||||||
@@ -896,8 +884,8 @@ export function InputBox({
|
|||||||
>
|
>
|
||||||
<PromptInputActionMenuTrigger
|
<PromptInputActionMenuTrigger
|
||||||
className={cn(
|
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",
|
"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 && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
compact &&
|
||||||
(frontendRagActive
|
(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"
|
? "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>
|
</PromptInputActionMenu>
|
||||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||||
<PromptInputActionMenu>
|
<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">
|
<div className="text-xs font-normal">
|
||||||
{t.inputBox.reasoningEffort}:
|
{t.inputBox.reasoningEffort}:
|
||||||
{context.reasoning_effort === "minimal" &&
|
{context.reasoning_effort === "minimal" &&
|
||||||
@@ -1300,11 +1288,10 @@ export function InputBox({
|
|||||||
>
|
>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
data-firefox52-smoke="model-selector"
|
|
||||||
className={cn(
|
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",
|
"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 &&
|
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">
|
<div className="flex min-w-0 flex-col items-start text-left">
|
||||||
@@ -1465,7 +1452,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Tooltip content={t.inputBox.addAttachments}>
|
<Tooltip content={t.inputBox.addAttachments}>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn("!px-2", className)}
|
className={cn("px-2!", className)}
|
||||||
onClick={() => attachments.openFileDialog()}
|
onClick={() => attachments.openFileDialog()}
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="size-3" />
|
<PaperclipIcon className="size-3" />
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
|
import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
|
||||||
// import {
|
import {
|
||||||
// useAssistantKnowledgeVisibility,
|
useAssistantKnowledgeVisibility,
|
||||||
// useUpdateAssistantKnowledgeVisibility,
|
useUpdateAssistantKnowledgeVisibility,
|
||||||
// } from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
loadOrdinaryKnowledgeLanding,
|
loadOrdinaryKnowledgeLanding,
|
||||||
@@ -30,7 +30,7 @@ export function KnowledgeProductNav() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
// const assistantVisibility = useAssistantKnowledgeVisibility();
|
const assistantVisibility = useAssistantKnowledgeVisibility();
|
||||||
const landing = useOrdinaryKnowledgeLanding();
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
const saveLanding = useMutation({
|
const saveLanding = useMutation({
|
||||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
@@ -40,11 +40,11 @@ export function KnowledgeProductNav() {
|
|||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
// const assistantAllowed = canAccessAssistantKnowledge(
|
const assistantAllowed = canAccessAssistantKnowledge(
|
||||||
// isAdmin,
|
isAdmin,
|
||||||
// assistantVisibility.isError ? undefined : assistantVisibility.data,
|
assistantVisibility.isError ? undefined : assistantVisibility.data,
|
||||||
// );
|
);
|
||||||
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
||||||
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
||||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
const ordinary = !assistant && !retrievalDebug;
|
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}`)}>
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
<BookOpen />普通知识库
|
<BookOpen />普通知识库
|
||||||
</Button>
|
</Button>
|
||||||
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
|
|
||||||
{assistantAllowed && (
|
{assistantAllowed && (
|
||||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
<Database />助手知识库
|
<Database />助手知识库
|
||||||
@@ -65,7 +64,6 @@ export function KnowledgeProductNav() {
|
|||||||
<SearchCheck />检索调试
|
<SearchCheck />检索调试
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
*/}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<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>
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
|
|
||||||
助手知识库日常展示
|
助手知识库日常展示
|
||||||
<Select
|
<Select
|
||||||
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
|
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
|
||||||
@@ -88,7 +85,6 @@ export function KnowledgeProductNav() {
|
|||||||
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
|
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ import {
|
|||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
|
hasCompleteArtifactExtension,
|
||||||
isAutoPreviewableArtifactPath,
|
isAutoPreviewableArtifactPath,
|
||||||
languageOfStreamingArtifact,
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
namedStreamingWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
} from "@/core/artifacts/streaming-write-preview";
|
} from "@/core/artifacts/streaming-write-preview";
|
||||||
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
normalizeSearchResults,
|
normalizeSearchResults,
|
||||||
@@ -57,12 +57,9 @@ import {
|
|||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
|
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
|
||||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
|
||||||
import {
|
import {
|
||||||
applyInlineCitations,
|
applyInlineCitations,
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
@@ -86,7 +83,6 @@ import { SourceCard } from "../skill-display-sources";
|
|||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
||||||
import { RetrievedFileList } from "./retrieved-file-list";
|
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
className,
|
className,
|
||||||
@@ -94,17 +90,14 @@ export function MessageGroup({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
displaySources = [],
|
displaySources = [],
|
||||||
ragModeEnabled = false,
|
ragModeEnabled = false,
|
||||||
threadId,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
threadId?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
|
||||||
const [showAbove, setShowAbove] = useState(
|
const [showAbove, setShowAbove] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
@@ -120,12 +113,6 @@ export function MessageGroup({
|
|||||||
[messages, writingModeEnabled],
|
[messages, writingModeEnabled],
|
||||||
);
|
);
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const retrievedFiles = useMemo(
|
|
||||||
() => citationDisplay?.retrieved_files_enabled === true
|
|
||||||
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
|
|
||||||
: [],
|
|
||||||
[messages, skills, citationDisplay?.retrieved_files_enabled],
|
|
||||||
);
|
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[ragModeEnabled, skills],
|
[ragModeEnabled, skills],
|
||||||
@@ -284,7 +271,6 @@ export function MessageGroup({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ChainOfThought>
|
</ChainOfThought>
|
||||||
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
|
|
||||||
<SingleRoundRagCitationDrawer
|
<SingleRoundRagCitationDrawer
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -795,10 +781,17 @@ function ArtifactWriteToolCall({
|
|||||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||||
const content = typeof args.content === "string" ? args.content : "";
|
const content = typeof args.content === "string" ? args.content : "";
|
||||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||||
const sourcePath = namedStreamingWriteFilePath(rawPath);
|
const hasUsablePath =
|
||||||
const showInlineContent = name === "write_file" && !sourcePath;
|
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||||
|
const sourcePath = hasUsablePath
|
||||||
|
? normalizedPath
|
||||||
|
: name === "write_file" && content.trim()
|
||||||
|
? provisionalWriteFilePath(content)
|
||||||
|
: null;
|
||||||
const previewKey = messageId && id ? `${messageId}:${id}` : 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 =
|
const artifactPath =
|
||||||
sourcePath && name === "write_file" && messageId && id
|
sourcePath && name === "write_file" && messageId && id
|
||||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||||
@@ -839,9 +832,7 @@ function ArtifactWriteToolCall({
|
|||||||
artifacts.setOpen(true);
|
artifacts.setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showInlineContent ? (
|
{(normalizedPath || sourcePath) && (
|
||||||
content && <InlineWritePreview content={content} />
|
|
||||||
) : (normalizedPath || sourcePath) && (
|
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||||
</ChainOfThoughtSearchResult>
|
</ChainOfThoughtSearchResult>
|
||||||
@@ -850,28 +841,6 @@ function ArtifactWriteToolCall({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineWritePreview({ content }: { content: string }) {
|
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
|
||||||
const follow = useRef(true);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (follow.current && ref.current) {
|
|
||||||
ref.current.scrollTop = ref.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [content]);
|
|
||||||
return (
|
|
||||||
<pre
|
|
||||||
ref={ref}
|
|
||||||
onScroll={(event) => {
|
|
||||||
const node = event.currentTarget;
|
|
||||||
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
|
|
||||||
}}
|
|
||||||
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
|
|
||||||
>
|
|
||||||
<code>{content}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenericCoTStep<T extends string = string> {
|
interface GenericCoTStep<T extends string = string> {
|
||||||
id?: string;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -904,37 +873,14 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
};
|
};
|
||||||
steps.push(step);
|
steps.push(step);
|
||||||
}
|
}
|
||||||
const writes = new Map(
|
for (const tool_call of message.tool_calls ?? []) {
|
||||||
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
|
if (tool_call.name === "task") {
|
||||||
);
|
|
||||||
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
|
|
||||||
.tool_call_chunks;
|
|
||||||
const toolCalls = [...(message.tool_calls ?? [])];
|
|
||||||
for (const write of writes.values()) {
|
|
||||||
if (
|
|
||||||
!toolCalls.some((call) => call.id === write.id) &&
|
|
||||||
chunks?.some(
|
|
||||||
(chunk) => chunk.id === write.id && chunk.name === "write_file",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
toolCalls.push({
|
|
||||||
id: write.id,
|
|
||||||
name: "write_file",
|
|
||||||
args: { path: write.path, content: write.content },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const tool_call of toolCalls) {
|
|
||||||
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const write = tool_call.name === "write_file" && tool_call.id
|
const writePath =
|
||||||
? writes.get(tool_call.id)
|
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||||
: undefined;
|
? (tool_call.args as { path: string }).path
|
||||||
const args = write
|
: "";
|
||||||
? { ...tool_call.args, path: write.path, content: write.content }
|
|
||||||
: tool_call.args;
|
|
||||||
const writePath = typeof args?.path === "string" ? args.path : "";
|
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
tool_call.name === "write_file" ||
|
tool_call.name === "write_file" ||
|
||||||
@@ -952,7 +898,7 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
name: tool_call.name,
|
name: tool_call.name,
|
||||||
args,
|
args: tool_call.args,
|
||||||
};
|
};
|
||||||
const toolCallId = tool_call.id;
|
const toolCallId = tool_call.id;
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
isHiddenFromUIMessage,
|
isHiddenFromUIMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { resolveRagModeRequested, useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -412,10 +412,11 @@ export function MessageList({
|
|||||||
const knowledgeReferencesEnabled =
|
const knowledgeReferencesEnabled =
|
||||||
threadContext?.knowledgeReferencesEnabled === true ||
|
threadContext?.knowledgeReferencesEnabled === true ||
|
||||||
!!threadContext?.knowledgeReferences;
|
!!threadContext?.knowledgeReferences;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled = resolveRagModeRequested({
|
||||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
referenceModeEntryEnabled,
|
||||||
isThreadRagModeLocked(threadId) ||
|
ragModeEnabled: settings.display.ragModeEnabled,
|
||||||
knowledgeReferencesEnabled;
|
knowledgeSelected: knowledgeReferencesEnabled,
|
||||||
|
});
|
||||||
const hasSingleRoundRag = useMemo(
|
const hasSingleRoundRag = useMemo(
|
||||||
() => messages.some((message) => isSingleRoundRagMessage(message)),
|
() => messages.some((message) => isSingleRoundRagMessage(message)),
|
||||||
[messages],
|
[messages],
|
||||||
@@ -912,7 +913,6 @@ export function MessageList({
|
|||||||
results.push(
|
results.push(
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
key={"thinking-group-" + message.id}
|
key={"thinking-group-" + message.id}
|
||||||
threadId={threadId}
|
|
||||||
messages={[message]}
|
messages={[message]}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
displaySources={resolveDisplaySources(message)}
|
displaySources={resolveDisplaySources(message)}
|
||||||
@@ -966,7 +966,6 @@ export function MessageList({
|
|||||||
const processingBody = (
|
const processingBody = (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
threadId={threadId}
|
|
||||||
messages={group.messages}
|
messages={group.messages}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
displaySources={resolveDisplaySources(processingAi)}
|
displaySources={resolveDisplaySources(processingAi)}
|
||||||
|
|||||||
@@ -74,18 +74,6 @@ export function SpeedLegend({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
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 fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||||
const display =
|
const display =
|
||||||
@@ -97,7 +85,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1.5"
|
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 }} />
|
<GaugeIcon className="size-3" style={{ color }} />
|
||||||
<span className="font-mono tabular-nums" 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,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 —
|
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||||
* hence the 401s) and expose it as an object URL for inline display.
|
* hence the 401s) and expose it as an object URL for inline display.
|
||||||
*/
|
*/
|
||||||
export function useAuthedObjectUrl(
|
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||||
url: string | null,
|
|
||||||
enabled: boolean,
|
|
||||||
requestFile: typeof apiFetch = apiFetch,
|
|
||||||
) {
|
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -174,10 +170,9 @@ export function useAuthedObjectUrl(
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let created: string | null = null;
|
let created: string | null = null;
|
||||||
const controller = new AbortController();
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
requestFile(url, { signal: controller.signal })
|
apiFetch(url)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
@@ -196,10 +191,9 @@ export function useAuthedObjectUrl(
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
|
||||||
if (created) URL.revokeObjectURL(created);
|
if (created) URL.revokeObjectURL(created);
|
||||||
};
|
};
|
||||||
}, [url, enabled, requestFile]);
|
}, [url, enabled]);
|
||||||
|
|
||||||
return { objectUrl, loading, error };
|
return { objectUrl, loading, error };
|
||||||
}
|
}
|
||||||
@@ -219,9 +213,6 @@ export function UploadedFilePreviewDialog({
|
|||||||
fileUrl,
|
fileUrl,
|
||||||
threadId,
|
threadId,
|
||||||
virtualPath,
|
virtualPath,
|
||||||
requestFile = apiFetch,
|
|
||||||
onDownload,
|
|
||||||
downloading = false,
|
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -231,11 +222,6 @@ export function UploadedFilePreviewDialog({
|
|||||||
/** Used by the bounded preview endpoint; optional for old/public messages. */
|
/** Used by the bounded preview endpoint; optional for old/public messages. */
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
virtualPath?: 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;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -256,7 +242,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
// image / pdf → fetch as authed blob and display via object URL.
|
// image / pdf → fetch as authed blob and display via object URL.
|
||||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||||
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
|
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||||
|
|
||||||
// text / office → fetch text (office reads the converted sibling .md).
|
// text / office → fetch text (office reads the converted sibling .md).
|
||||||
const textUrls = useMemo(
|
const textUrls = useMemo(
|
||||||
@@ -293,7 +279,6 @@ export function UploadedFilePreviewDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
|
||||||
setTextLoading(true);
|
setTextLoading(true);
|
||||||
setTextError(null);
|
setTextError(null);
|
||||||
setText(null);
|
setText(null);
|
||||||
@@ -308,7 +293,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
)}/artifact-preview?path=${encodeURIComponent(
|
)}/artifact-preview?path=${encodeURIComponent(
|
||||||
previewPath,
|
previewPath,
|
||||||
)}&max_chars=${maxPreviewChars}`;
|
)}&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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const body = (await response.json()) as ArtifactPreviewResponse;
|
const body = (await response.json()) as ArtifactPreviewResponse;
|
||||||
const rawContent = String(body.content ?? "");
|
const rawContent = String(body.content ?? "");
|
||||||
@@ -335,8 +320,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
for (const textUrl of textUrls) {
|
for (const textUrl of textUrls) {
|
||||||
try {
|
try {
|
||||||
const response = await requestFile(textUrl, {
|
const response = await apiFetch(textUrl, {
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Range: `bytes=0-${maxBytes - 1}` },
|
headers: { Range: `bytes=0-${maxBytes - 1}` },
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -372,9 +356,8 @@ export function UploadedFilePreviewDialog({
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
|
||||||
};
|
};
|
||||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
|
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
|
||||||
|
|
||||||
const renderAsMarkdown =
|
const renderAsMarkdown =
|
||||||
kind === "office" || ext === "md" || ext === "markdown";
|
kind === "office" || ext === "md" || ext === "markdown";
|
||||||
@@ -393,36 +376,21 @@ export function UploadedFilePreviewDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(56rem, calc(100vw - 2rem))",
|
width: "min(56rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
height: "min(720px, calc(100dvh - 2rem))",
|
||||||
height: "min(720px, calc(100vh - 2rem))",
|
|
||||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
||||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||||
{onDownload ? (
|
<a
|
||||||
<Button
|
href={fileUrl}
|
||||||
type="button"
|
target="_blank"
|
||||||
size="sm"
|
rel="noopener noreferrer"
|
||||||
variant="ghost"
|
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||||
disabled={downloading}
|
>
|
||||||
onClick={() => void onDownload()}
|
<DownloadIcon className="size-3" />
|
||||||
>
|
{t.common.download}
|
||||||
<DownloadIcon className="size-3" />
|
</a>
|
||||||
{t.common.download}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={fileUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-3" />
|
|
||||||
{t.common.download}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -431,7 +399,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
blobLoading ? (
|
blobLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : blobError || !objectUrl ? (
|
) : 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">
|
<div className="flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
@@ -445,7 +413,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
blobLoading ? (
|
blobLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : blobError || !objectUrl ? (
|
) : blobError || !objectUrl ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
@@ -457,7 +425,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
textLoading ? (
|
textLoading ? (
|
||||||
loadingView
|
loadingView
|
||||||
) : textError ? (
|
) : textError ? (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{previewMeta &&
|
{previewMeta &&
|
||||||
@@ -495,7 +463,7 @@ export function UploadedFilePreviewDialog({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -503,34 +471,15 @@ export function UploadedFilePreviewDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
|
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||||
fileUrl: string;
|
|
||||||
label: string;
|
|
||||||
onDownload?: () => void | Promise<void>;
|
|
||||||
downloading?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
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">
|
<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>
|
<Button asChild size="sm" variant="outline">
|
||||||
{onDownload ? (
|
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={downloading}
|
|
||||||
onClick={() => void onDownload()}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-3.5" />
|
<DownloadIcon className="size-3.5" />
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</a>
|
||||||
) : (
|
</Button>
|
||||||
<Button asChild size="sm" variant="outline">
|
|
||||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
|
||||||
<DownloadIcon className="size-3.5" />
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,37 +7,29 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
|||||||
import { getUsageMetadata } from "@/core/messages/usage";
|
import { getUsageMetadata } from "@/core/messages/usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
|
|
||||||
import {
|
/**
|
||||||
GENERATION_IDLE_MS,
|
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
||||||
SAMPLE_THROTTLE_MS,
|
* speed indicator for mixed Chinese/English replies without dragging in a
|
||||||
charsToTokensPerSec,
|
* real tokeniser.
|
||||||
messageHasToolCalls,
|
*/
|
||||||
pushSpeedSample,
|
const CHARS_PER_TOKEN = 2;
|
||||||
rollingTokensPerSec,
|
|
||||||
shouldFreezeGeneration,
|
/** Sample throttle while a message is streaming. */
|
||||||
usageTokensPerSec,
|
const SAMPLE_THROTTLE_MS = 60;
|
||||||
type SpeedSample,
|
|
||||||
} from "./speed-metrics";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-AI-message metric: live rolling rate during that model-call segment,
|
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||||
* frozen once the segment ends (not when the whole agent turn ends).
|
|
||||||
*/
|
*/
|
||||||
export interface MessageMetric {
|
export interface MessageMetric {
|
||||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||||
tokensPerSec: number;
|
tokensPerSec: number;
|
||||||
/**
|
/**
|
||||||
* Wall-clock duration of this model-call segment: first visible character
|
* Wall-clock duration from when the message first appeared with text to
|
||||||
* → last visible character (tool-wait after the call is excluded).
|
* either *now* (active) or to when the stream ended (frozen).
|
||||||
*/
|
*/
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
/** True while this message is still generating visible tokens. */
|
/** True while this is the currently streaming message. */
|
||||||
isActive: boolean;
|
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
|
* Whether the speed value was computed from real usage_metadata output
|
||||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||||
@@ -53,12 +45,15 @@ export interface MessageMetric {
|
|||||||
isTurnFinal: boolean;
|
isTurnFinal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Sample = { at: number; chars: number };
|
||||||
|
|
||||||
type TimingEntry = {
|
type TimingEntry = {
|
||||||
firstSeenAt: number;
|
firstSeenAt: number;
|
||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
lastGrewAt: number;
|
samples: Sample[];
|
||||||
samples: SpeedSample[];
|
|
||||||
lastChars: number;
|
lastChars: number;
|
||||||
|
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||||
|
hadUsage: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isAiMessage(message: Message): boolean {
|
function isAiMessage(message: Message): boolean {
|
||||||
@@ -87,12 +82,11 @@ function messageText(message: Message): string {
|
|||||||
*
|
*
|
||||||
* Recording strategy:
|
* Recording strategy:
|
||||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||||
* - On every text-length change we push a sample.
|
* - On every text-length change we push a sample and recompute the rate.
|
||||||
* - The segment freezes when this model call ends (next AI message, tool
|
* - When `isLoading` flips to false we mark *all* still-active messages as
|
||||||
* calls, usage + idle, or the run stops) so tool-wait is not in the
|
* ended so subsequent renders return a frozen duration.
|
||||||
* denominator.
|
* - Token totals come from usage_metadata when present (most accurate);
|
||||||
* - Live rate is a rolling window that decays to 0 on pause.
|
* otherwise we fall back to char-count / 2.
|
||||||
* - `usage_metadata.output_tokens` only corrects the frozen final value.
|
|
||||||
*/
|
*/
|
||||||
export function useMessageMetrics(
|
export function useMessageMetrics(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
@@ -110,8 +104,7 @@ export function useMessageMetrics(
|
|||||||
// synchronously inside an effect so React isn't tracking the map identity.
|
// synchronously inside an effect so React isn't tracking the map identity.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const grewIds = new Set<string>();
|
let activeId: string | null = null;
|
||||||
let latestId: string | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i];
|
const message = messages[i];
|
||||||
@@ -125,62 +118,50 @@ export function useMessageMetrics(
|
|||||||
entry = {
|
entry = {
|
||||||
firstSeenAt: now,
|
firstSeenAt: now,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
lastGrewAt: now,
|
|
||||||
samples: [{ at: now, chars: text.length }],
|
samples: [{ at: now, chars: text.length }],
|
||||||
lastChars: text.length,
|
lastChars: text.length,
|
||||||
|
hadUsage: false,
|
||||||
};
|
};
|
||||||
timingRef.current.set(id, entry);
|
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;
|
const grew = text.length !== entry.lastChars;
|
||||||
if (grew) {
|
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||||
grewIds.add(id);
|
entry.samples.push({ at: now, chars: text.length });
|
||||||
entry.lastGrewAt = now;
|
entry.lastChars = text.length;
|
||||||
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
if (entry.samples.length > 256) {
|
||||||
pushSpeedSample(entry.samples, now, text.length);
|
entry.samples.splice(0, entry.samples.length - 256);
|
||||||
lastSampleAtRef.current = now;
|
|
||||||
}
|
}
|
||||||
|
lastSampleAtRef.current = now;
|
||||||
|
} else if (grew) {
|
||||||
|
// Still update lastChars so the next render captures it eventually.
|
||||||
entry.lastChars = text.length;
|
entry.lastChars = text.length;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
if (getUsageMetadata(message)) {
|
||||||
const message = messages[i];
|
entry.hadUsage = true;
|
||||||
if (!message || !isAiMessage(message)) continue;
|
|
||||||
const id = getMessageId(message, i);
|
|
||||||
const entry = timingRef.current.get(id);
|
|
||||||
if (!entry) continue;
|
|
||||||
|
|
||||||
const grewThisTick = grewIds.has(id);
|
|
||||||
if (grewThisTick && entry.endedAt != null && isLoading) {
|
|
||||||
entry.endedAt = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = getUsageMetadata(message);
|
|
||||||
if (
|
|
||||||
shouldFreezeGeneration({
|
|
||||||
endedAt: entry.endedAt,
|
|
||||||
isLoading,
|
|
||||||
isLatestGenerating: id === latestId,
|
|
||||||
hasToolCalls: messageHasToolCalls(message),
|
|
||||||
grewThisTick,
|
|
||||||
lastGrewAt: entry.lastGrewAt,
|
|
||||||
now,
|
|
||||||
outputTokens: usage?.outputTokens ?? 0,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
entry.endedAt = entry.lastGrewAt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastActiveIdRef.current = 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);
|
setTick((v) => v + 1);
|
||||||
}, [messages, isLoading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||||
@@ -217,36 +198,45 @@ export function useMessageMetrics(
|
|||||||
const entry = timingRef.current.get(id);
|
const entry = timingRef.current.get(id);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
const isActive =
|
|
||||||
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
|
|
||||||
const endedAt = entry.endedAt ?? now;
|
const endedAt = entry.endedAt ?? now;
|
||||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||||
|
const isActive = isLoading && id === lastActiveIdRef.current;
|
||||||
|
|
||||||
let tokensPerSec = 0;
|
let tokensPerSec = 0;
|
||||||
let fromUsage = false;
|
let fromUsage = false;
|
||||||
let isPaused = false;
|
|
||||||
const usage = getUsageMetadata(message);
|
const usage = getUsageMetadata(message);
|
||||||
|
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||||
if (entry.endedAt != null) {
|
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
fromUsage = true;
|
||||||
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
|
|
||||||
fromUsage = true;
|
|
||||||
} else if (durationMs > 0) {
|
|
||||||
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
|
// Char-count fallback. Uses the rolling window during streaming so a
|
||||||
isPaused =
|
// mid-stream snapshot reflects current speed rather than average since
|
||||||
isActive &&
|
// the start.
|
||||||
tokensPerSec <= 0 &&
|
if (isActive && entry.samples.length >= 2) {
|
||||||
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
|
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, {
|
out.set(id, {
|
||||||
tokensPerSec,
|
tokensPerSec,
|
||||||
durationMs,
|
durationMs,
|
||||||
isActive,
|
isActive,
|
||||||
isPaused,
|
|
||||||
fromUsage,
|
fromUsage,
|
||||||
isTurnFinal: turnFinalIds.has(id),
|
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,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
extractContentFromMessage,
|
||||||
|
stripArtifactTag,
|
||||||
|
stripUploadedFilesTag,
|
||||||
|
} from "@/core/messages/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
|
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
export { getQuestionAnchorId } from "./question-navigator-data";
|
|
||||||
|
|
||||||
/** Width of the expanded floating panel, in px. */
|
/** Width of the expanded floating panel, in px. */
|
||||||
const PANEL_WIDTH = 300;
|
const PANEL_WIDTH = 300;
|
||||||
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
||||||
const PANEL_GUTTER_MARGIN = 24;
|
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. */
|
/** Questions needed before the navigator shows up at all. */
|
||||||
const MIN_QUESTIONS = 2;
|
const MIN_QUESTIONS = 2;
|
||||||
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
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. */
|
/** Walk up from a message anchor to the element that actually scrolls. */
|
||||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||||
let node = el?.parentElement ?? null;
|
let node = el?.parentElement ?? null;
|
||||||
@@ -81,7 +128,16 @@ export function QuestionNavigator({
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
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;
|
const visible = questions.length >= MIN_QUESTIONS;
|
||||||
|
|
||||||
|
|||||||
@@ -45,13 +45,10 @@ import { useLocalSettings } from "@/core/settings";
|
|||||||
import {
|
import {
|
||||||
useAppearanceSettings,
|
useAppearanceSettings,
|
||||||
useCitationDisplaySettings,
|
useCitationDisplaySettings,
|
||||||
useHtmlMapSettings,
|
|
||||||
useLayoutMode,
|
useLayoutMode,
|
||||||
useUpdateAppearanceSettings,
|
useUpdateAppearanceSettings,
|
||||||
useUpdateCitationDisplaySettings,
|
useUpdateCitationDisplaySettings,
|
||||||
useUpdateHtmlMapSettings,
|
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import type { HtmlMapEngine } from "@/core/system-settings/api";
|
|
||||||
import type {
|
import type {
|
||||||
CompactShortcutButton,
|
CompactShortcutButton,
|
||||||
CompactShortcutRouteParam,
|
CompactShortcutRouteParam,
|
||||||
@@ -188,8 +185,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
|
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||||
const { data: htmlMap } = useHtmlMapSettings();
|
|
||||||
const updateHtmlMap = useUpdateHtmlMapSettings();
|
|
||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||||
@@ -872,53 +867,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">HTML 地图</div>
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
|
||||||
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={htmlMap?.engine ?? "echarts"}
|
|
||||||
disabled={!htmlMap || updateHtmlMap.isPending}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value !== "echarts" && value !== "leaflet") return;
|
|
||||||
updateHtmlMap.mutate(value as HtmlMapEngine, {
|
|
||||||
onError: (error) => toast.error(error.message || "保存失败"),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[160px]" aria-label="HTML 地图">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="echarts">ECharts</SelectItem>
|
|
||||||
<SelectItem value="leaflet">地图网页</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">技能检索文件展示</div>
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
|
||||||
全局设置。开启后,问答步骤下展示技能检索到的文件,支持预览和下载;不展示生成文件。默认开启。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={citationDisplay?.retrieved_files_enabled ?? false}
|
|
||||||
disabled={!citationDisplay || updateCitationDisplay.isPending}
|
|
||||||
onCheckedChange={(checked) => updateCitationDisplay.mutate(
|
|
||||||
{ retrieved_files_enabled: checked },
|
|
||||||
{ onError: (error) => toast.error(error.message || "保存失败") },
|
|
||||||
)}
|
|
||||||
aria-label="技能检索文件展示"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{referenceModeEntryEnabled && (
|
{referenceModeEntryEnabled && (
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -226,9 +226,7 @@ export function SkillDetailDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(48rem, calc(100vw - 2rem))",
|
width: "min(48rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
height: "min(760px, calc(100dvh - 2rem))",
|
||||||
height: "min(760px, calc(100vh - 2rem))",
|
|
||||||
maxHeight: "min(760px, calc(100vh - 2rem))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||||
|
|||||||
@@ -149,9 +149,7 @@ export function SkillFilePreviewDialog({
|
|||||||
style={{
|
style={{
|
||||||
width: "min(56rem, calc(100vw - 2rem))",
|
width: "min(56rem, calc(100vw - 2rem))",
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
height: "min(720px, calc(100dvh - 2rem))",
|
||||||
height: "min(720px, calc(100vh - 2rem))",
|
|
||||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
|||||||
baseTileLoaded = true;
|
baseTileLoaded = true;
|
||||||
setTileFailed(false);
|
setTileFailed(false);
|
||||||
};
|
};
|
||||||
const onError = (event: maplibregl.MapEventType["error"]) => {
|
const onError = (event: maplibregl.ErrorEvent) => {
|
||||||
const message = event.error?.message ?? "";
|
const message = event.error?.message ?? "";
|
||||||
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
|
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",
|
path: "/page/workspace/scheduled/tasks",
|
||||||
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
|
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// id: "workspace-knowledge",
|
id: "workspace-knowledge",
|
||||||
// label: "知识库管理",
|
label: "知识库管理",
|
||||||
// icon: BookOpen,
|
icon: BookOpen,
|
||||||
// path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
// match: (pathname) =>
|
match: (pathname) =>
|
||||||
// pathname.startsWith("/page/workspace/knowledge") &&
|
pathname.startsWith("/page/workspace/knowledge") &&
|
||||||
// !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
...MARKDOWN_DOCX_MENU_ITEM,
|
...MARKDOWN_DOCX_MENU_ITEM,
|
||||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
TimerIcon,
|
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -266,22 +265,6 @@ export function WorkspaceNavMenu() {
|
|||||||
技能管理
|
技能管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
|
||||||
>
|
|
||||||
<TimerIcon />
|
|
||||||
技能耗时排查
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
|
||||||
>
|
|
||||||
<ScrollTextIcon />
|
|
||||||
模型收发记录
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/models")}
|
onClick={() => navigate("/page/workspace/admin/models")}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,6 @@
|
|||||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||||
|
|
||||||
import { getAuthorizationHeaderValue } from "../auth";
|
import { getAuthorizationHeaderValue } from "../auth";
|
||||||
import {
|
|
||||||
createDeadlineSignal,
|
|
||||||
installAbortSignalTimeout,
|
|
||||||
} from "../compat/abort-signal";
|
|
||||||
import { getLangGraphBaseURL } from "../config";
|
import { getLangGraphBaseURL } from "../config";
|
||||||
|
|
||||||
import { withCsrfHeaders } from "./csrf";
|
import { withCsrfHeaders } from "./csrf";
|
||||||
@@ -17,66 +13,6 @@ import {
|
|||||||
} from "./stream-cursor";
|
} from "./stream-cursor";
|
||||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
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 {
|
function streamEventId(event: unknown): string | undefined {
|
||||||
if (
|
if (
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
@@ -95,10 +31,6 @@ function createCompatibleClient(
|
|||||||
): LangGraphClient {
|
): LangGraphClient {
|
||||||
const client = new LangGraphClient({
|
const client = new LangGraphClient({
|
||||||
apiUrl: getLangGraphBaseURL(isMock),
|
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) {
|
onRequest(_url, init) {
|
||||||
const headers = withCsrfHeaders(init.headers);
|
const headers = withCsrfHeaders(init.headers);
|
||||||
const authHeader = getAuthorizationHeaderValue();
|
const authHeader = getAuthorizationHeaderValue();
|
||||||
@@ -112,7 +44,6 @@ function createCompatibleClient(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
installLegacyControlTimeout(client.threads);
|
|
||||||
|
|
||||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||||
client.runs.stream = (async function* (threadId, assistantId, payload) {
|
client.runs.stream = (async function* (threadId, assistantId, payload) {
|
||||||
|
|||||||
@@ -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;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
items: ArtifactLibraryItem[];
|
items: ArtifactLibraryItem[];
|
||||||
syncing?: boolean;
|
|
||||||
syncStatus?: string | null;
|
|
||||||
indexedAt?: string | null;
|
|
||||||
syncError?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchArtifactLibrary({
|
export async function fetchArtifactLibrary({
|
||||||
@@ -91,10 +87,6 @@ export async function fetchArtifactLibrary({
|
|||||||
total?: number;
|
total?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
syncing?: boolean;
|
|
||||||
sync_status?: string | null;
|
|
||||||
indexed_at?: string | null;
|
|
||||||
sync_error?: string | null;
|
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
thread_title: string;
|
thread_title: string;
|
||||||
@@ -119,10 +111,6 @@ export async function fetchArtifactLibrary({
|
|||||||
total: data.total ?? 0,
|
total: data.total ?? 0,
|
||||||
offset: data.offset ?? offset,
|
offset: data.offset ?? offset,
|
||||||
limit: data.limit ?? limit,
|
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) => ({
|
items: (data.items ?? []).map((item) => ({
|
||||||
threadId: item.thread_id,
|
threadId: item.thread_id,
|
||||||
threadTitle: item.thread_title,
|
threadTitle: item.thread_title,
|
||||||
|
|||||||
@@ -70,14 +70,8 @@ function matchingToolArgChunks(
|
|||||||
const byId = toolCall.id
|
const byId = toolCall.id
|
||||||
? chunks.filter((chunk) => chunk?.id && chunk.id === 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
|
const selected = byId.length
|
||||||
? chunks.filter((chunk) => {
|
? byId
|
||||||
if (chunk?.id) return chunk.id === toolCall.id;
|
|
||||||
if (anchoredIndex == null || chunk?.index !== anchoredIndex) return false;
|
|
||||||
return !toolCall.name || !chunk?.name || chunk.name === toolCall.name;
|
|
||||||
})
|
|
||||||
: chunks.filter((chunk) => {
|
: chunks.filter((chunk) => {
|
||||||
if (toolCall.name && chunk?.name && chunk.name !== toolCall.name) {
|
if (toolCall.name && chunk?.name && chunk.name !== toolCall.name) {
|
||||||
return false;
|
return false;
|
||||||
@@ -97,15 +91,12 @@ function matchingToolArgChunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStreamingWriteFileArgs(
|
export function resolveStreamingWriteFileArgs(
|
||||||
message: object,
|
message: { tool_call_chunks?: readonly ToolCallChunkLike[] },
|
||||||
toolCall: ToolCallLike,
|
toolCall: ToolCallLike,
|
||||||
): { path: string; content: string } {
|
): { path: string; content: string } {
|
||||||
const chunks = (message as {
|
|
||||||
tool_call_chunks?: readonly ToolCallChunkLike[];
|
|
||||||
}).tool_call_chunks;
|
|
||||||
const direct = asRecord(toolCall.args);
|
const direct = asRecord(toolCall.args);
|
||||||
const fromChunks = parsePartialJsonObject(
|
const fromChunks = parsePartialJsonObject(
|
||||||
accumulateToolArgChunks(matchingToolArgChunks(chunks, toolCall)),
|
accumulateToolArgChunks(matchingToolArgChunks(message.tool_call_chunks, toolCall)),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
path: pickLongerString(direct?.path, fromChunks?.path),
|
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);
|
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("/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/scan_structure.py"), false);
|
||||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), 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 {
|
export function isProvisionalStreamingArtifactPath(path: string): boolean {
|
||||||
const pathname =
|
const pathname =
|
||||||
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
|
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
|
||||||
return PROVISIONAL_WRITE_FILE_NAMES.has(pathname.replace(/^\/+/, ""));
|
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||||
}
|
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
|
||||||
|
|
||||||
/** A missing/partial tool path is inline text, never a synthesized file. */
|
|
||||||
export function namedStreamingWriteFilePath(path: string): string | null {
|
|
||||||
const normalized = normalizeStreamingArtifactPath(path);
|
|
||||||
return normalized &&
|
|
||||||
hasCompleteArtifactExtension(normalized) &&
|
|
||||||
!isProvisionalStreamingArtifactPath(normalized)
|
|
||||||
? normalized
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
|
/** 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.
|
* an empty sandbox before the real output exists.
|
||||||
*/
|
*/
|
||||||
export function isAutoPreviewableArtifactPath(path: string): boolean {
|
export function isAutoPreviewableArtifactPath(path: string): boolean {
|
||||||
|
if (isProvisionalStreamingArtifactPath(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const normalized = normalizeStreamingArtifactPath(path)
|
const normalized = normalizeStreamingArtifactPath(path)
|
||||||
.split(/[?#]/, 1)[0]
|
.split(/[?#]/, 1)[0]
|
||||||
?.replace(/^\/+/, "")
|
?.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");
|
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([
|
const preview = latestInFlightWriteFilePreview([
|
||||||
{
|
{
|
||||||
id: "m1",
|
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", () => {
|
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/);
|
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", () => {
|
test("resolveStreamingWriteFileArgs grows content from snapshot chunks", () => {
|
||||||
const args = resolveStreamingWriteFileArgs(
|
const args = resolveStreamingWriteFileArgs(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import {
|
|||||||
} from "./parse-partial-json";
|
} from "./parse-partial-json";
|
||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
|
hasCompleteArtifactExtension,
|
||||||
isAutoPreviewableArtifactPath,
|
isAutoPreviewableArtifactPath,
|
||||||
languageOfStreamingArtifact,
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
namedStreamingWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
} from "./streaming-write-preview";
|
} from "./streaming-write-preview";
|
||||||
|
|
||||||
export { resolveStreamingWriteFileArgs } from "./parse-partial-json";
|
export { resolveStreamingWriteFileArgs } from "./parse-partial-json";
|
||||||
@@ -249,7 +250,16 @@ export function latestInFlightWriteFilePreview(
|
|||||||
if (hasToolResult(thread, toolCall.id)) {
|
if (hasToolResult(thread, toolCall.id)) {
|
||||||
continue;
|
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) {
|
if (!sourcePath) {
|
||||||
continue;
|
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(
|
return markdown.replace(
|
||||||
/(^|[^!])\[([^\]\n]+)\]\(([^)\n]+)\)/g,
|
/(?<!!)\[([^\]\n]+)\]\(([^)\n]+)\)/g,
|
||||||
(original, prefix: string, rawLabel: string, rawUrl: string) => {
|
(original, rawLabel: string, rawUrl: string) => {
|
||||||
if (!isLegacyWikiPlaceholderUrl(rawUrl)) return original;
|
if (!isLegacyWikiPlaceholderUrl(rawUrl)) return original;
|
||||||
const normalizedLabel = normalizeWikiLinkLabel(rawLabel);
|
const normalizedLabel = normalizeWikiLinkLabel(rawLabel);
|
||||||
const withoutSummarySuffix = normalizedLabel.replace(/\s+-\s+summary$/i, "");
|
const withoutSummarySuffix = normalizedLabel.replace(/\s+-\s+summary$/i, "");
|
||||||
const target = pagesByLabel.get(normalizedLabel) || pagesByLabel.get(withoutSummarySuffix);
|
const target = pagesByLabel.get(normalizedLabel) || pagesByLabel.get(withoutSummarySuffix);
|
||||||
if (!target) return original;
|
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 || "")
|
["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
|
const catalogue = isIndex && existingMarkdownLinkCount === 0
|
||||||
? buildWikiIndexCatalogMarkdown(pages, knowledgeBaseId, buildPageUrl, indexGroups)
|
? 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 type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
import {
|
import { groupMessages, isHiddenFromUIMessage, isViewedImagesContextMessage, stripUploadedFilesTag } from "./utils";
|
||||||
groupMessages,
|
|
||||||
hasOnlyInternalToolCalls,
|
|
||||||
hasVisibleToolCalls,
|
|
||||||
isHiddenFromUIMessage,
|
|
||||||
isViewedImagesContextMessage,
|
|
||||||
stripUploadedFilesTag,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
function groupTypes(messages: Message[]) {
|
function groupTypes(messages: Message[]) {
|
||||||
const types: string[] = [];
|
const types: string[] = [];
|
||||||
@@ -220,173 +213,6 @@ test("tool messages still attach to an open processing group", () => {
|
|||||||
assert.deepEqual(types, ["assistant:processing"]);
|
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", () => {
|
test("stripUploadedFilesTag removes a public image URL block", () => {
|
||||||
const content = [
|
const content = [
|
||||||
"<uploaded_files>",
|
"<uploaded_files>",
|
||||||
@@ -417,8 +243,7 @@ test("isViewedImagesContextMessage hides legacy view_image injection bubbles", (
|
|||||||
assert.equal(isViewedImagesContextMessage(legacy), true);
|
assert.equal(isViewedImagesContextMessage(legacy), true);
|
||||||
assert.equal(isHiddenFromUIMessage(legacy), true);
|
assert.equal(isHiddenFromUIMessage(legacy), true);
|
||||||
|
|
||||||
const types = groupMessages(
|
const types = groupMessages([legacy, { type: "ai", id: "a1", content: "ok" } as Message]).map(
|
||||||
[legacy, { type: "ai", id: "a1", content: "ok" } as Message],
|
|
||||||
(group) => group.type,
|
(group) => group.type,
|
||||||
);
|
);
|
||||||
assert.deepEqual(types, ["assistant"]);
|
assert.deepEqual(types, ["assistant"]);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
||||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
|
||||||
|
|
||||||
interface GenericMessageGroup<T = string> {
|
interface GenericMessageGroup<T = string> {
|
||||||
type: T;
|
type: T;
|
||||||
@@ -56,7 +55,6 @@ export function groupMessages<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groups: MessageGroup[] = [];
|
const groups: MessageGroup[] = [];
|
||||||
const internalToolCallIds = new Set<string>();
|
|
||||||
|
|
||||||
// Returns the last group if it can still accept tool messages
|
// 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).
|
// (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)) {
|
if (isHiddenFromUIMessage(message)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (message.type === "ai") {
|
|
||||||
for (const toolCall of message.tool_calls ?? []) {
|
if (message.name === "todo_reminder") {
|
||||||
if (isInternalToolCallName(toolCall.name) && toolCall.id) {
|
|
||||||
internalToolCallIds.add(toolCall.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasOnlyInternalToolCalls(message)) {
|
|
||||||
// The whole handshake turn is transport state. Hiding just its tool call
|
|
||||||
// would leak any provider preface/reasoning as a standalone chat bubble.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
message.type === "tool" &&
|
|
||||||
(isInternalToolCallName(message.name) ||
|
|
||||||
internalToolCallIds.has(message.tool_call_id ?? ""))
|
|
||||||
) {
|
|
||||||
internalToolCallIds.delete(message.tool_call_id ?? "");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +210,7 @@ export function groupMessages<T>(
|
|||||||
type: "assistant:subagent",
|
type: "assistant:subagent",
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
} else if (hasReasoning(message) || hasVisibleToolCalls(message)) {
|
} else if (hasReasoning(message) || hasToolCalls(message)) {
|
||||||
const lastGroup = groups[groups.length - 1];
|
const lastGroup = groups[groups.length - 1];
|
||||||
// Accumulate consecutive intermediate AI messages into one processing group.
|
// Accumulate consecutive intermediate AI messages into one processing group.
|
||||||
if (lastGroup?.type !== "assistant:processing") {
|
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
|
// 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.
|
// 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] });
|
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) {
|
export function hasPresentFiles(message: Message) {
|
||||||
return (
|
return (
|
||||||
message.type === "ai" &&
|
message.type === "ai" &&
|
||||||
@@ -672,15 +633,6 @@ export function isHiddenFromUIMessage(message: Message) {
|
|||||||
if (message.additional_kwargs?.hide_from_ui === true) {
|
if (message.additional_kwargs?.hide_from_ui === true) {
|
||||||
return 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.
|
// ViewImageMiddleware injects vision context for the model only.
|
||||||
if (isViewedImagesContextMessage(message)) {
|
if (isViewedImagesContextMessage(message)) {
|
||||||
return true;
|
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,42 +52,8 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const CJK_TEXT_RE = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/;
|
const CJK_TEXT_RE =
|
||||||
type WordSegmenterConstructor = new (
|
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||||
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() {
|
export function rehypeSplitWordsIntoSpans() {
|
||||||
return (tree: Root) => {
|
return (tree: Root) => {
|
||||||
@@ -105,13 +71,17 @@ export function rehypeSplitWordsIntoSpans() {
|
|||||||
newChildren.push(child);
|
newChildren.push(child);
|
||||||
return;
|
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) => {
|
words.forEach((word: string) => {
|
||||||
newChildren.push({
|
newChildren.push({
|
||||||
type: "element",
|
type: "element",
|
||||||
tagName: "span",
|
tagName: "span",
|
||||||
properties: {
|
properties: {
|
||||||
className: ["animate-fade-in"],
|
className: "animate-fade-in",
|
||||||
},
|
},
|
||||||
children: [{ type: "text", value: word }],
|
children: [{ type: "text", value: word }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
getLastAgentId,
|
getLastAgentId,
|
||||||
isThreadRagModeLocked,
|
isThreadRagModeLocked,
|
||||||
lockThreadRagMode,
|
lockThreadRagMode,
|
||||||
|
resolveRagModeRequested,
|
||||||
saveLastAgentId,
|
saveLastAgentId,
|
||||||
} from "./local";
|
} from "./local";
|
||||||
export type { LocalSettings } from "./local";
|
export type { LocalSettings } from "./local";
|
||||||
@@ -165,6 +165,27 @@ export function lockThreadRagMode(threadId: string) {
|
|||||||
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
|
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this turn should actually run 参考文献/RAG citation mode.
|
||||||
|
* The admin entry and the user toggle must both be on. A leftover
|
||||||
|
* per-thread lock must not keep injecting citations after the user
|
||||||
|
* (or admin) turned the mode off. Selected knowledge spaces still
|
||||||
|
* opt in so their source documents can render in the panel.
|
||||||
|
*/
|
||||||
|
export function resolveRagModeRequested({
|
||||||
|
referenceModeEntryEnabled,
|
||||||
|
ragModeEnabled,
|
||||||
|
knowledgeSelected = false,
|
||||||
|
}: {
|
||||||
|
referenceModeEntryEnabled: boolean;
|
||||||
|
ragModeEnabled: boolean;
|
||||||
|
knowledgeSelected?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
(referenceModeEntryEnabled && ragModeEnabled) || knowledgeSelected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function applyThreadModelOverride(
|
export function applyThreadModelOverride(
|
||||||
settings: LocalSettings,
|
settings: LocalSettings,
|
||||||
threadModelName: string | undefined,
|
threadModelName: string | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { resolveRagModeRequested } from "./local.ts";
|
||||||
|
|
||||||
|
test("stays off when the admin entry is closed even if the old toggle is on", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveRagModeRequested({
|
||||||
|
referenceModeEntryEnabled: false,
|
||||||
|
ragModeEnabled: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stays off when the user closed the 参考文献 toggle", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveRagModeRequested({
|
||||||
|
referenceModeEntryEnabled: true,
|
||||||
|
ragModeEnabled: false,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turns on only when both the admin entry and the user toggle are on", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveRagModeRequested({
|
||||||
|
referenceModeEntryEnabled: true,
|
||||||
|
ragModeEnabled: true,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selected knowledge spaces still opt in without the composer toggle", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveRagModeRequested({
|
||||||
|
referenceModeEntryEnabled: false,
|
||||||
|
ragModeEnabled: false,
|
||||||
|
knowledgeSelected: true,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,19 +2,11 @@ import { apiFetch } from "@/core/api";
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
|
|
||||||
export type WordHeadingNumberingStyle =
|
|
||||||
| "hybrid_decimal"
|
|
||||||
| "official_chinese"
|
|
||||||
| "decimal"
|
|
||||||
| "chapter_section"
|
|
||||||
| "none";
|
|
||||||
|
|
||||||
export interface PromptPrefixSettings {
|
export interface PromptPrefixSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
switch_label: string;
|
switch_label: string;
|
||||||
prompt_prefix: string;
|
prompt_prefix: string;
|
||||||
ordinary_qa_markdown_format_enabled: boolean;
|
ordinary_qa_markdown_format_enabled: boolean;
|
||||||
word_heading_numbering_style: WordHeadingNumberingStyle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||||
@@ -142,7 +134,6 @@ export interface PromptPrefixUpdate {
|
|||||||
switch_label?: string;
|
switch_label?: string;
|
||||||
prompt_prefix?: string;
|
prompt_prefix?: string;
|
||||||
ordinary_qa_markdown_format_enabled?: boolean;
|
ordinary_qa_markdown_format_enabled?: boolean;
|
||||||
word_heading_numbering_style?: WordHeadingNumberingStyle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromPluginManagementDto(data: {
|
function fromPluginManagementDto(data: {
|
||||||
@@ -279,23 +270,15 @@ export interface PrivateSquareUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CitationDisplaySettings {
|
export interface CitationDisplaySettings {
|
||||||
retrieved_files_enabled: boolean;
|
|
||||||
reference_mode_enabled: boolean;
|
reference_mode_enabled: boolean;
|
||||||
cleanup_words: string[];
|
cleanup_words: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CitationDisplayUpdate {
|
export interface CitationDisplayUpdate {
|
||||||
retrieved_files_enabled?: boolean;
|
|
||||||
reference_mode_enabled?: boolean;
|
reference_mode_enabled?: boolean;
|
||||||
cleanup_words?: string[];
|
cleanup_words?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HtmlMapEngine = "echarts" | "leaflet";
|
|
||||||
|
|
||||||
export interface HtmlMapSettings {
|
|
||||||
engine: HtmlMapEngine;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaderboardSettings {
|
export interface LeaderboardSettings {
|
||||||
exclude_user_ids: string[];
|
exclude_user_ids: string[];
|
||||||
include_scheduled: boolean;
|
include_scheduled: boolean;
|
||||||
@@ -614,51 +597,6 @@ export async function updateLeaderboardSettings(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadHtmlMapSettings(): Promise<HtmlMapSettings> {
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${getBackendBaseURL()}/api/system-settings/html-map`,
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load HTML map settings: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as HtmlMapSettings;
|
|
||||||
writeYLog({
|
|
||||||
optType: OptType.view,
|
|
||||||
optDetail: "CM助手-全量-系统设置-HTML地图查看",
|
|
||||||
url: "/api/system-settings/html-map",
|
|
||||||
returnParam: data,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateHtmlMapSettings(
|
|
||||||
body: HtmlMapSettings,
|
|
||||||
): Promise<HtmlMapSettings> {
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${getBackendBaseURL()}/api/system-settings/html-map`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "Failed to update HTML map settings" }));
|
|
||||||
throw new Error(detail.detail ?? "Failed to update HTML map settings");
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as HtmlMapSettings;
|
|
||||||
writeYLog({
|
|
||||||
optType: OptType.edit,
|
|
||||||
optDetail: "CM助手-全量-系统设置-HTML地图编辑",
|
|
||||||
url: "/api/system-settings/html-map",
|
|
||||||
reqParam: body,
|
|
||||||
returnParam: data,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCitationDisplaySettings(
|
export async function updateCitationDisplaySettings(
|
||||||
body: CitationDisplayUpdate,
|
body: CitationDisplayUpdate,
|
||||||
): Promise<CitationDisplaySettings> {
|
): Promise<CitationDisplaySettings> {
|
||||||
|
|||||||
Loaded 100 of 601 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user