From b164e7054154d11580b5a87da65a173539c763d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 08:48:53 +0800 Subject: [PATCH 1/4] fix: unlock send immediately after SSE close Normal Q&A no longer waits on GET /runs before the next message. Probe run status only on 409 or dropped streams. Co-authored-by: Cursor --- frontend-web/src/core/threads/hooks.ts | 229 +++++++----------- .../src/core/threads/stream-display.test.ts | 25 ++ .../src/core/threads/stream-display.ts | 19 ++ 3 files changed, 138 insertions(+), 135 deletions(-) diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 5f760442..47a81023 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -31,6 +31,7 @@ import { import { collectHumanMessageIds, hasServerEchoedOptimisticHuman, + isStaleStreamFinish, mergeStreamDisplayValues, shouldDiscardTerminalOverlay, } from "./stream-display"; @@ -190,11 +191,9 @@ function getRunMetadataStorage(): { /** * SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}`` - * but **deletes that key on stream onSuccess**. A premature SSE close - * (proxy timeout / false-positive disconnect) looks like success, so the - * reconnect key vanishes while the backend run is still occupying the - * thread. We keep a second key the SDK never touches and only clear it - * after ``runs.get`` confirms a terminal status. + * but **deletes that key on stream onSuccess**. We keep a second key the SDK + * never touches so a 409 / dropped-SSE exception can still rejoin. Normal + * SSE close clears it immediately — do not wait for ``runs.get``. */ const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:"; @@ -276,36 +275,18 @@ export async function stopThreadRun( /** * 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run * 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。 + * + * 不在发送前 GET /runs —— 协助卡出现时图多半已经结束,为兜底去查会让每次 + * 点选项都卡住。未在流式则直接发;真遇到 409 再走 confirmOrRejoin。 */ export async function stopActiveThreadBeforeClarificationReply( threadId: string | null | undefined, thread: { isLoading?: boolean; stop: () => void | Promise }, ): Promise { - let storedRunId: string | null = null; - if (threadId && typeof window !== "undefined") { - try { - storedRunId = - normalizeStoredRunId( - window.sessionStorage.getItem(`lg:stream:${threadId}`), - ) || readActiveRunId(threadId); - } catch { - storedRunId = null; - } + if (!thread.isLoading) { + if (threadId) clearActiveRunId(threadId); + return; } - // 协助卡出现时图已经 jump_to=end,run 多半已是 terminal。再 stop()/cancel - // 会再触发 onFinish → getState overlay,把刚发出的用户回复冻住、整段攒着出。 - if (storedRunId && threadId) { - try { - const run = await getAPIClient().runs.get(threadId, storedRunId); - if (run && !isInflightRunStatus(run.status)) { - clearActiveRunId(threadId); - return; - } - } catch { - /* 404 / 网络:按下面的 best-effort stop 走 */ - } - } - if (!thread.isLoading && !storedRunId) return; try { await stopThreadRun(threadId); } catch { @@ -320,7 +301,7 @@ export async function stopActiveThreadBeforeClarificationReply( await new Promise((resolve) => setTimeout(resolve, 120)); } -function getStreamErrorMessage(error: unknown): string { +function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; } @@ -340,21 +321,21 @@ function getStreamErrorMessage(error: unknown): string { return nestedError; } } - return "Request failed."; -} - -/** - * React production error #185 is the minified form of "Maximum update depth - * exceeded". It is a client render-loop guard, not a backend/model failure, - * so it must never be surfaced as the answer's stream error. - */ -function isReactMaximumUpdateDepthError(message: string): boolean { - return ( - /maximum update depth exceeded/i.test(message) || - /minified react error\s*#185\b/i.test(message) || - /react\.dev\/errors\/185\b/i.test(message) - ); -} + return "Request failed."; +} + +/** + * React production error #185 is the minified form of "Maximum update depth + * exceeded". It is a client render-loop guard, not a backend/model failure, + * so it must never be surfaced as the answer's stream error. + */ +function isReactMaximumUpdateDepthError(message: string): boolean { + return ( + /maximum update depth exceeded/i.test(message) || + /minified react error\s*#185\b/i.test(message) || + /react\.dev\/errors\/185\b/i.test(message) + ); +} const TERMINAL_RUN_STATUSES = new Set([ "success", @@ -363,12 +344,9 @@ const TERMINAL_RUN_STATUSES = new Set([ "interrupted", ]); -// A completed server run normally sends a terminal SSE event. In local -// development, however, a Vite hot update, a proxy disconnect, or a brief -// network interruption can make the browser miss that final event while the -// backend continues (and successfully completes) the run. Do not probe on -// every render: only treat the stream as suspicious after it has been quiet -// for a while, then reconcile it from the authoritative run/state endpoints. +// Only probe /runs after an *exception* path has already set streamHoldOpen +// (dropped SSE, 409, React #185). Normal Q&A must not GET /runs on finish or +// every 5s — that made every send wait for a status round-trip. const STREAM_RECONCILIATION_IDLE_MS = 12_000; const STREAM_RECONCILIATION_POLL_MS = 5_000; // A run can legitimately be quiet while a long tool call / slow model write is @@ -608,14 +586,9 @@ export function useThreadStream({ "terminal" | "rejoined" | "unknown" > >(async () => "unknown"); - // Keep the send button in "stop" until we confirm the backend run is - // terminal. A premature SSE close must not look like the turn ended. - const [streamHoldOpen, setStreamHoldOpen] = useState( - () => - Boolean( - threadId && typeof window !== "undefined" && readActiveRunId(threadId), - ), - ); + // Held only on exception recovery (dropped SSE / 409). Normal turns rely + // on SDK `isLoading` so the send button unlocks as soon as SSE closes. + const [streamHoldOpen, setStreamHoldOpen] = useState(false); const sendInFlightRef = useRef(false); const sendEpochRef = useRef(0); // Filled after useStream; onFinish/onError may read it asynchronously. @@ -687,17 +660,17 @@ export function useThreadStream({ runMetadataStorageRef.current = getRunMetadataStorage(); } - const thread = useStream({ - client: getAPIClient(isMock), - assistantId: assistantId ?? "lead_agent", - threadId: onStreamThreadId, - // The SDK's current implementation falls back to synchronous external- - // store notifications when this option is omitted. A network chunk can - // contain dozens of token events, causing nested React renders and the - // intermittent production-only React #185 error. Coalesce notifications - // to one visual update per frame; stream data itself is not dropped. - throttle: 16, - reconnectOnMount: runMetadataStorageRef.current + const thread = useStream({ + client: getAPIClient(isMock), + assistantId: assistantId ?? "lead_agent", + threadId: onStreamThreadId, + // The SDK's current implementation falls back to synchronous external- + // store notifications when this option is omitted. A network chunk can + // contain dozens of token events, causing nested React renders and the + // intermittent production-only React #185 error. Coalesce notifications + // to one visual update per frame; stream data itself is not dropped. + throttle: 16, + reconnectOnMount: runMetadataStorageRef.current ? () => runMetadataStorageRef.current! : false, fetchStateHistory: { limit: 1 }, @@ -707,7 +680,6 @@ export function useThreadStream({ if (meta.run_id) { activeRunIdRef.current = meta.run_id; persistActiveRunId(meta.thread_id, meta.run_id); - setStreamHoldOpen(true); } const metadataAgentId = context.agent_id ?? context.agent_name; const extraMetadata = @@ -862,21 +834,21 @@ export function useThreadStream({ toast(e.message); } }, - onError(error) { - markStreamActivity(); - const message = getStreamErrorMessage(error); - // A synchronous SDK store notification used to let React's update-depth - // guard escape into the stream promise. Do not present that internal UI - // exception as a model failure. Reconcile/rejoin on the next task so the - // backend run can continue and the current answer is preserved. - if (isReactMaximumUpdateDepthError(message)) { - window.setTimeout(() => { - setStreamHoldOpen(true); - void confirmOrRejoinRef.current("error"); - }, 0); - return; - } - // Replacing a stalled local SSE reader aborts the in-flight fetch. That + onError(error) { + markStreamActivity(); + const message = getStreamErrorMessage(error); + // A synchronous SDK store notification used to let React's update-depth + // guard escape into the stream promise. Do not present that internal UI + // exception as a model failure. Reconcile/rejoin on the next task so the + // backend run can continue and the current answer is preserved. + if (isReactMaximumUpdateDepthError(message)) { + window.setTimeout(() => { + setStreamHoldOpen(true); + void confirmOrRejoinRef.current("error"); + }, 0); + return; + } + // Replacing a stalled local SSE reader aborts the in-flight fetch. That // is expected and must not look like a user-facing "network" failure. if (isAbortLikeError(error)) { return; @@ -944,34 +916,30 @@ export function useThreadStream({ } toast.error(message); }, - onFinish(state) { + onFinish(state, runMeta) { lastStreamActivityAtRef.current = Date.now(); - // Do not trust a clean SSE close as "the run is done". streamResumable - // chats keep the backend run alive on disconnect; the SDK still fires - // onFinish and would otherwise restore the send button. - // - // If a newer turn is already streaming (common: 用户协助卡提交后,上一轮 - // interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would - // steal the live SSE and dump tokens only at the end. - if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { + // Trust a clean SSE close. Querying /runs here blocked every send for + // ~5s to paper over a rare premature disconnect; that case is handled + // when the next send hits 409 (or onError for a dropped stream). + if ( + isStaleStreamFinish({ + finishingRunId: runMeta?.run_id, + currentRunId: activeRunIdRef.current, + }) + ) { return; } - setStreamHoldOpen(true); - void confirmOrRejoinRef.current("finish").then((result) => { - if (result === "rejoined") { - return; - } - if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { - return; - } - setStreamHoldOpen(false); - // run 真正终结(非 rejoin):若深度研究报告流仍 active(tool_finished - // 帧丢失/被取消),标记中断,避免进度条永久转圈。 - const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null; - if (finThreadId) finalizeResearchReportStream(finThreadId); + const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + if (finThreadId) { + clearActiveRunId(finThreadId); + finalizeResearchReportStream(finThreadId); + } + activeRunIdRef.current = null; + setStreamHoldOpen(false); + if (state?.values) { listeners.current.onFinish?.(state.values); - void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - }); + } + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); }, }); @@ -1009,14 +977,6 @@ export function useThreadStream({ if (streamReconciliationInFlightRef.current && reason === "poll") { return "unknown"; } - // Stale finish/error from a prior interrupt must not joinStream over a - // turn the user already started (协助卡提交后的实时流会被打断并攒批)。 - if ( - (reason === "finish" || reason === "error") && - (sendInFlightRef.current || threadStreamRef.current?.isLoading) - ) { - return "rejoined"; - } streamReconciliationInFlightRef.current = true; const epochAtStart = sendEpochRef.current; try { @@ -1192,11 +1152,10 @@ export function useThreadStream({ if (dropped && idleFor < 800) { return; } - const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId); - if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) { - return; - } - if (!stored && !streamHoldOpen) { + // Do not probe just because a run id is stored. Only the exception + // path sets streamHoldOpen; polling otherwise made every turn wait + // on GET /runs before the user could send again. + if (!streamHoldOpen) { return; } await confirmOrRejoin("poll"); @@ -1705,20 +1664,20 @@ export function useThreadStream({ thread.isLoading, ) ?? thread.values; - const mergedMessages = + const mergedMessages = Array.isArray(mergedValues?.messages) ? (mergedValues.messages as Message[]) - : thread.messages; - - const visibleThreadError = - thread.error && - isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) - ? undefined - : thread.error; - - const mergedThread = { - ...thread, - error: visibleThreadError, + : thread.messages; + + const visibleThreadError = + thread.error && + isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) + ? undefined + : thread.error; + + const mergedThread = { + ...thread, + error: visibleThreadError, values: mergedValues, messages: optimisticMessages.length > 0 diff --git a/frontend-web/src/core/threads/stream-display.test.ts b/frontend-web/src/core/threads/stream-display.test.ts index bc3216d3..15898caf 100644 --- a/frontend-web/src/core/threads/stream-display.test.ts +++ b/frontend-web/src/core/threads/stream-display.test.ts @@ -5,6 +5,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { collectHumanMessageIds, hasServerEchoedOptimisticHuman, + isStaleStreamFinish, mergeStreamDisplayValues, shouldDiscardTerminalOverlay, } from "./stream-display"; @@ -28,6 +29,30 @@ test("live streaming ignores frozen checkpoint messages so tokens can paint", () assert.deepEqual(afterStream?.messages, overrides.messages); }); +test("late onFinish of a prior run is stale once a newer run is live", () => { + assert.equal( + isStaleStreamFinish({ + finishingRunId: "old-run", + currentRunId: "new-run", + }), + true, + ); + assert.equal( + isStaleStreamFinish({ + finishingRunId: "run-1", + currentRunId: "run-1", + }), + false, + ); + assert.equal( + isStaleStreamFinish({ + finishingRunId: "run-1", + currentRunId: null, + }), + false, + ); +}); + test("a later send discards the previous run's getState overlay", () => { assert.equal( shouldDiscardTerminalOverlay({ diff --git a/frontend-web/src/core/threads/stream-display.ts b/frontend-web/src/core/threads/stream-display.ts index 23690af2..66812cf9 100644 --- a/frontend-web/src/core/threads/stream-display.ts +++ b/frontend-web/src/core/threads/stream-display.ts @@ -51,6 +51,25 @@ export function shouldDiscardTerminalOverlay(opts: { return false; } +/** + * True when an SSE onFinish belongs to a *previous* run, after the user + * already started a newer turn (协助卡提交是典型场景). + * + * Do **not** use `sendInFlight` / `isLoading` for this: `submit()` stays + * pending until the current stream ends, so those flags are still true on a + * normal same-turn finish and would skip unlock for every Q&A. + */ +export function isStaleStreamFinish(opts: { + finishingRunId?: string | null; + currentRunId?: string | null; +}): boolean { + return Boolean( + opts.finishingRunId && + opts.currentRunId && + opts.finishingRunId !== opts.currentRunId, + ); +} + export function collectHumanMessageIds( ...groups: Array ): Set { From 31cfa9340877d4d255658ecebaf191a232cbe464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 08:53:34 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=A1=B5=E5=BC=95=E5=AF=BC=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91=EF=BC=8C=E5=AE=8C=E6=88=90?= =?UTF-8?q?/=E8=B7=B3=E8=BF=87=E4=B8=8D=E5=86=8D=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentGuide 新增 autoStart 属性(默认 true),列表页挂载传 false:进智能体列表页不再自动弹引导,仅由头部「使用引导」按钮手动触发 - 去掉 list 段与创建页的联动:finish 不再 router.push,最后一步按钮恢复默认「完成」,跳过/走完都就地结束并写记忆 - AGENTS.md 同步更新智能体引导条目 --- AGENTS.md | 2 +- .../workspace/agents/agent-gallery.tsx | 6 +++- .../workspace/agents/agent-guide.tsx | 33 +++++++++---------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d2228e7..a4969d93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 - **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。 -- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;最后一步按钮文案改为「去创建智能体 →」,点完成即 `router.push` 到创建页衔接下一段;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 +- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, 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 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 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`. diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 31421cee..be0409e4 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -615,7 +615,11 @@ export function AgentGallery() { )} - + ); } diff --git a/frontend-web/src/components/workspace/agents/agent-guide.tsx b/frontend-web/src/components/workspace/agents/agent-guide.tsx index 78f82f50..2f109b12 100644 --- a/frontend-web/src/components/workspace/agents/agent-guide.tsx +++ b/frontend-web/src/components/workspace/agents/agent-guide.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; import { Guide, type GuideStep } from "tdesign-react"; import { env } from "@/env"; @@ -13,7 +12,8 @@ import { isWujieEnv } from "@/core/embed/wujie"; * 「浏览管理 → 创建与配置 → 发起问答」流程: * * - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、 - * 创建入口;最后一步「去创建智能体 →」直接跳到创建页衔接下一段。 + * 创建入口;完成 / 跳过就地结束,不跳转创建页(创建页有自己的一段 + * 引导,点「创建智能体」进入时自动演示)。 * - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、 * 知识空间、SOUL.md、技能、保存。 * - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与 @@ -23,6 +23,10 @@ import { isWujieEnv } from "@/core/embed/wujie"; * localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开 * 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。 * + * 自动演示范围:只有 create 段(进入新建页)与 chat 段自动弹;list 段挂在 + * 智能体列表页但不自动演示(`autoStart={false}`,避免一进列表页就弹引导), + * 仅由头部「使用引导」按钮手动触发。 + * * 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤 * (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的 * 目标元素会抛错,不能盲传。 @@ -121,7 +125,7 @@ const LIST_STEPS: StepSpec[] = [ mode: "popup", placement: "bottom-left", title: "创建智能体", - body: "准备好了就点这里,开始创建你的专属智能体。点「去创建智能体」直接进入下一站。", + body: "准备好了就点这里,开始创建你的专属智能体;进入创建页后还会有一段配置引导。", }, ]; @@ -260,17 +264,19 @@ function buildSteps(stage: AgentGuideStage): GuideStep[] { } /** - * 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为false 时完全 - * 静默(不监听、不自动弹)。挂在三个页面各自的作用域内。 + * 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为 false 时完全 + * 静默(不监听、不自动弹)。挂在三个页面各自的作用域内;`autoStart` 为 false + * 时仅响应手动触发(列表页不自动弹,进新建页才自动演示)。 */ export function AgentGuide({ stage, eligible, + autoStart = true, }: { stage: AgentGuideStage; eligible: boolean; + autoStart?: boolean; }) { - const router = useRouter(); const [current, setCurrent] = useState(-1); const stepsRef = useRef([]); @@ -285,7 +291,7 @@ export function AgentGuide({ // 「已看过」只由 finish 回调(完成/跳过)写入。 const autoTriedRef = useRef(false); useEffect(() => { - if (!eligible || autoTriedRef.current) return; + if (!eligible || !autoStart || autoTriedRef.current) return; autoTriedRef.current = true; if (isStageFinished(stage)) return; const timer = setTimeout(start, START_DELAY_MS); @@ -294,7 +300,7 @@ export function AgentGuide({ // 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。 autoTriedRef.current = false; }; - }, [eligible, stage, start]); + }, [eligible, autoStart, stage, start]); // 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。 useEffect(() => { @@ -305,15 +311,11 @@ export function AgentGuide({ window.removeEventListener(startEventName(stage), handler); }, [eligible, stage, start]); - // 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」。 - // list 阶段的最后一步按钮文案是「去创建智能体 →」,完成即跳转衔接下一段。 + // 完成 / 跳过都是用户显式结束引导,就地关闭并记忆「已看过」,不跳转任何页面。 const finish = useCallback(() => { markStageFinished(stage); setCurrent(-1); - if (stage === "list") { - router.push("/page/workspace/agents/new"); - } - }, [stage, router]); + }, [stage]); if (!eligible) return null; @@ -324,9 +326,6 @@ export function AgentGuide({ onChange={setCurrent} onFinish={finish} onSkip={finish} - finishButtonProps={ - stage === "list" ? { content: "去创建智能体 →" } : undefined - } /> ); } From 7c15ca6710dd0635fe00b70ae7eb4426c7d62034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 09:05:29 +0800 Subject: [PATCH 3/4] fix: show sandbox flowcharts on Win7 Chrome and repair common Mermaid syntax Disable HTML labels so nodes use native SVG text, and rewrite bare edge labels plus invalid class statements before render. Co-authored-by: Cursor --- frontend-web/scripts/check-chrome109-css.mjs | 5 + .../workspace/messages/markdown-content.tsx | 1 + frontend-web/src/core/streamdown/plugins.ts | 5 + .../streamdown/remark-sanitize-mermaid.ts | 18 ++++ .../src/core/utils/mermaid-sanitize.test.ts | 71 ++++++++++++ frontend-web/src/core/utils/mermaid.ts | 101 +++++++++++++++++- 6 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts create mode 100644 frontend-web/src/core/utils/mermaid-sanitize.test.ts diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index e1c5325a..2f32b8ed 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -31,6 +31,11 @@ const requiredLegacyFallbacks = [ pattern: //, description: "Prompt textarea must not rely on :has() for its parent height", }, + { + 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", + }, ]; for (const file of cssFiles) { diff --git a/frontend-web/src/components/workspace/messages/markdown-content.tsx b/frontend-web/src/components/workspace/messages/markdown-content.tsx index b83053f2..9288b3a2 100644 --- a/frontend-web/src/components/workspace/messages/markdown-content.tsx +++ b/frontend-web/src/components/workspace/messages/markdown-content.tsx @@ -143,6 +143,7 @@ export function MarkdownContent({ className={className} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} + mermaidConfig={streamdownPlugins.mermaidConfig} components={components} > {content} diff --git a/frontend-web/src/core/streamdown/plugins.ts b/frontend-web/src/core/streamdown/plugins.ts index ff8ab11b..48357fd2 100644 --- a/frontend-web/src/core/streamdown/plugins.ts +++ b/frontend-web/src/core/streamdown/plugins.ts @@ -5,22 +5,27 @@ import remarkMath from "remark-math"; import type { StreamdownProps } from "streamdown"; import { rehypeSplitWordsIntoSpans } from "../rehype"; +import { SANDBOX_MERMAID_CONFIG } from "../utils/mermaid"; +import { remarkSanitizeMermaid } from "./remark-sanitize-mermaid"; export const streamdownPlugins = { remarkPlugins: [ remarkGfm, [remarkMath, { singleDollarTextMath: true }], + remarkSanitizeMermaid, ] as StreamdownProps["remarkPlugins"], rehypePlugins: [ rehypeRaw, [rehypeKatex, { output: "html" }], ] as StreamdownProps["rehypePlugins"], + mermaidConfig: SANDBOX_MERMAID_CONFIG, }; export const streamdownPluginsWithWordAnimation = { remarkPlugins: [ remarkGfm, [remarkMath, { singleDollarTextMath: true }], + remarkSanitizeMermaid, ] as StreamdownProps["remarkPlugins"], rehypePlugins: [ [rehypeKatex, { output: "html" }], diff --git a/frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts b/frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts new file mode 100644 index 00000000..aa7d9252 --- /dev/null +++ b/frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts @@ -0,0 +1,18 @@ +import { visit } from "unist-util-visit"; + +import { sanitizeMermaidSource } from "../utils/mermaid"; + +type MermaidCodeNode = { + lang?: string | null; + value: string; +}; + +export function remarkSanitizeMermaid() { + return (tree: unknown) => { + visit(tree as never, "code", (node: MermaidCodeNode) => { + const lang = (node.lang ?? "").toLowerCase(); + if (lang !== "mermaid" && lang !== "mmd") return; + node.value = sanitizeMermaidSource(node.value); + }); + }; +} diff --git a/frontend-web/src/core/utils/mermaid-sanitize.test.ts b/frontend-web/src/core/utils/mermaid-sanitize.test.ts new file mode 100644 index 00000000..ea7099d2 --- /dev/null +++ b/frontend-web/src/core/utils/mermaid-sanitize.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sanitizeMermaidSource } from "./mermaid"; + +test("inserts a missing arrow before a bare edge label", () => { + const input = "E --> G|价值输出 V_s| H[PPO 损失]"; + assert.equal( + sanitizeMermaidSource(input), + "E --> G -->|价值输出 V_s| H[PPO 损失]", + ); +}); + +test("leaves already-valid labeled edges unchanged", () => { + const input = "E -->|价值输出 V_s| H[PPO 损失]\nG -->|状态| I"; + assert.equal(sanitizeMermaidSource(input), input); +}); + +test("fixes a label attached to a shaped node", () => { + const input = "E[Actor 网络]|价值输出 V_s| H[PPO 损失]"; + assert.equal( + sanitizeMermaidSource(input), + "E[Actor 网络] -->|价值输出 V_s| H[PPO 损失]", + ); +}); + +test("fixes several bare labels on one line", () => { + const input = "A|x| B|y| C"; + assert.equal(sanitizeMermaidSource(input), "A -->|x| B -->|y| C"); +}); + +test("inserts a space after class when the model starts the id list with a comma", () => { + const input = " class,E,F,G,H,I,J,K loss;"; + assert.equal( + sanitizeMermaidSource(input), + " class E,F,G,H,I,J,K loss;", + ); +}); + +test("splits zip-assigned class names onto one statement per class", () => { + const input = " class A,B,C,D input,module,module,module;"; + assert.equal( + sanitizeMermaidSource(input), + " class A input;\n class B,C,D module;", + ); +}); + +test("leaves a valid class assignment and classDef alone", () => { + const input = [ + " classDef input fill:#e3f2fd,stroke:#1565c0;", + " class A,B input;", + ].join("\n"); + assert.equal(sanitizeMermaidSource(input), input); +}); + +test("repairs the DAC-PPO architecture diagram class block", () => { + const input = `graph TD + A[智能体观测 o_t] --> B[个体 Encoder] + E --> G -->|价值输出 V_s| H[PPO 损失] + classDef input fill:#e3f2fd,stroke:#1565c0; + classDef module fill:#fff3e0,stroke:#e65100; + classDef loss fill:#e8f5e9,stroke:#2e7d32; + class A,B,C,D input,module,module,module; + class,E,F,G,H,I,J,K loss;`; + const output = sanitizeMermaidSource(input); + assert.match(output, /class A input;/); + assert.match(output, /class B,C,D module;/); + assert.match(output, /class E,F,G,H,I,J,K loss;/); + assert.doesNotMatch(output, /class,/); + assert.doesNotMatch(output, /input,module/); +}); diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts index 01fa3e5f..9891b3ed 100644 --- a/frontend-web/src/core/utils/mermaid.ts +++ b/frontend-web/src/core/utils/mermaid.ts @@ -2,6 +2,100 @@ import { getFileName } from "./files"; let mermaidInitialized = false; +/** + * Keep sandbox diagrams on the SVG-only Mermaid rendering path. + * + * Mermaid's default HTML labels are implemented with SVG `foreignObject`. + * Although that looks a little richer in current browsers, many Windows 7 + * Chromium shells either do not support it or calculate its dimensions + * incorrectly, leaving nodes blank or overlapping. Native SVG text works in + * the Chrome 100 baseline used by this application and in older Win7 browser + * shells as well. + * + * `htmlLabels` is marked secure so a diagram's front matter/directive cannot + * switch this preview back to `foreignObject` rendering. + */ +export const SANDBOX_MERMAID_CONFIG = { + startOnLoad: false, + securityLevel: "strict" as const, + htmlLabels: false, + fontFamily: '"Microsoft YaHei", "Microsoft YaHei UI", SimSun, Arial, sans-serif', + flowchart: { + // A simple path is more reliable than the spline renderer in older + // Chromium SVG implementations and is easier to read at small sizes. + curve: "linear" as const, + useMaxWidth: false, + }, + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "htmlLabels", + ], +}; + +/** + * Models often emit almost-Mermaid that the parser rejects: + * - `A --> B|label| C` instead of `A --> B -->|label| C` + * - `class,E,F loss;` (comma after `class`) instead of `class E,F loss;` + * - `class A,B input,module;` (one class name per node) instead of + * separate `class` statements + * + * Already-valid `-->|label|` links and single-class assignments are kept. + */ +export function sanitizeMermaidSource(source: string): string { + return sanitizeMermaidClassStatements( + source + .replace( + /(^|[\s;])([A-Za-z][\w-]*|[\u4e00-\u9fff]+)\|([^|\n]+)\|/gm, + "$1$2 -->|$3|", + ) + .replace(/(\])\|([^|\n]+)\|/g, "$1 -->|$2|"), + ); +} + +const CLASS_NODE_LIST = /[A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*/; +const CLASS_NAME_LIST = /[A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*/; + +function sanitizeMermaidClassStatements(source: string): string { + return source.replace( + /^([ \t]*)class(?!Def\b)[ \t]*,?[ \t]*(\S[^\n]*)$/gm, + (full, indent: string, rest: string) => { + const trimmed = rest.replace(/;+\s*$/, "").trim(); + const matched = trimmed.match( + new RegExp(`^(${CLASS_NODE_LIST.source})\\s+(${CLASS_NAME_LIST.source})$`), + ); + if (!matched) { + return full; + } + const nodeIds = matched[1].split(/\s*,\s*/).filter(Boolean); + const classNames = matched[2].split(/\s*,\s*/).filter(Boolean); + if (nodeIds.length === 0 || classNames.length === 0) { + return full; + } + if (classNames.length === 1) { + return `${indent}class ${nodeIds.join(",")} ${classNames[0]};`; + } + const groups: Array<{ name: string; ids: string[] }> = []; + for (let index = 0; index < nodeIds.length; index += 1) { + const name = classNames[index] ?? classNames[classNames.length - 1]; + const last = groups[groups.length - 1]; + if (last && last.name === name) { + last.ids.push(nodeIds[index]); + } else { + groups.push({ name, ids: [nodeIds[index]] }); + } + } + return groups + .map((group) => `${indent}class ${group.ids.join(",")} ${group.name};`) + .join("\n"); + }, + ); +} + export function isMermaidFile(filepath: string): boolean { return /\.mmd$/i.test(filepath); } @@ -14,17 +108,14 @@ async function getMermaidModule() { const mod = await import("mermaid"); const mermaid = mod.default; if (!mermaidInitialized) { - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - }); + mermaid.initialize(SANDBOX_MERMAID_CONFIG); mermaidInitialized = true; } return mermaid; } export async function renderMermaidToSvg(source: string): Promise { - const text = source.trim(); + const text = sanitizeMermaidSource(source).trim(); if (!text) { throw new Error("Mermaid 内容为空,无法生成图片"); } From f18994fd38d7c27ae1d84700eb982c18c025a28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 09:25:09 +0800 Subject: [PATCH 4/4] fix: show generated HTML charts on Win7 Chrome Rewrite oklch/color-mix and space-separated rgb before iframe preview so pie and bar fills are not dropped on Chrome 109. Co-authored-by: Cursor --- frontend-web/scripts/check-chrome109-css.mjs | 5 + .../src/components/html-preview-frame.tsx | 4 +- .../artifacts/artifact-file-detail.tsx | 38 +- .../core/utils/legacy-html-preview.test.ts | 48 ++ .../src/core/utils/legacy-html-preview.ts | 491 ++++++++++++++++++ 5 files changed, 566 insertions(+), 20 deletions(-) create mode 100644 frontend-web/src/core/utils/legacy-html-preview.test.ts create mode 100644 frontend-web/src/core/utils/legacy-html-preview.ts diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index 2f32b8ed..1c779411 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -36,6 +36,11 @@ const requiredLegacyFallbacks = [ 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", + }, ]; for (const file of cssFiles) { diff --git a/frontend-web/src/components/html-preview-frame.tsx b/frontend-web/src/components/html-preview-frame.tsx index 2030e398..466987ce 100644 --- a/frontend-web/src/components/html-preview-frame.tsx +++ b/frontend-web/src/components/html-preview-frame.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { injectInPageAnchorHandler } from "@/core/utils/markdown"; +import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview"; /** * Sandboxed preview of a generated HTML document. @@ -26,7 +26,7 @@ export function HtmlPreviewFrame({ const [url, setUrl] = useState(); useEffect(() => { - const blob = new Blob([injectInPageAnchorHandler(html ?? "")], { + const blob = new Blob([prepareGeneratedHtml(html ?? "")], { type: "text/html;charset=utf-8", }); const objectUrl = URL.createObjectURL(blob); diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 5de1b271..b876ee17 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -97,9 +97,9 @@ import { openMermaidSvgInNewTab, renderMermaidToSvg, } from "@/core/utils/mermaid"; +import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview"; import { createMarkdownAnchorResolver, - injectInPageAnchorHandler, isInPageAnchor, scrollToInPageAnchor, } from "@/core/utils/markdown"; @@ -1422,7 +1422,9 @@ export function ArtifactFileDetail({ toast.error("文件内容为空,暂时无法在新窗口打开"); return; } - const blob = new Blob([source], { type: "text/html;charset=utf-8" }); + const blob = new Blob([prepareGeneratedHtml(source)], { + type: "text/html;charset=utf-8", + }); const url = URL.createObjectURL(blob); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); @@ -1460,7 +1462,7 @@ export function ArtifactFileDetail({ return ( - +
)}
-
- +
+ {canPublishToKnowledge && ( - -
- {/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的 - 动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */} - (onClose ? onClose() : setOpen(false))} - tooltip="收起文件面板" - /> - +
+
+ {/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的 + 动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */} + (onClose ? onClose() : setOpen(false))} + tooltip="收起文件面板" + /> + 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // Intercept in-page anchor clicks so they scroll within the iframe instead // of navigating the sandboxed blob: document (which blanks the frame). - const blob = new Blob([injectInPageAnchorHandler(content ?? "")], { + const blob = new Blob([prepareGeneratedHtml(content ?? "")], { type: "text/html;charset=utf-8", }); const url = URL.createObjectURL(blob); diff --git a/frontend-web/src/core/utils/legacy-html-preview.test.ts b/frontend-web/src/core/utils/legacy-html-preview.test.ts new file mode 100644 index 00000000..1a4a8757 --- /dev/null +++ b/frontend-web/src/core/utils/legacy-html-preview.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { downlevelModernColors, prepareGeneratedHtml } from "./legacy-html-preview"; + +test("rewrites oklch chart fills to comma rgb", () => { + const html = ``; + const out = downlevelModernColors(html); + assert.match(out, /fill="rgb\(\d+, \d+, \d+\)"/); + assert.doesNotMatch(out, /oklch\(/); +}); + +test("rewrites space-separated rgb and hsl used by pie slices", () => { + const html = `.slice{fill:rgb(255 99 71 / 80%);stroke:hsl(220 90% 56%)}`; + const out = downlevelModernColors(html); + assert.match(out, /rgba\(255, 99, 71, 0\.8\)/); + assert.match(out, /rgb\(\d+, \d+, \d+\)/); + assert.doesNotMatch(out, /rgb\(255 99 71/); + assert.doesNotMatch(out, /hsl\(220 90%/); +}); + +test("rewrites color-mix after nested oklch is converted", () => { + const html = `background:color-mix(in srgb, oklch(0.63 0.2 27) 40%, #ffffff);`; + const out = downlevelModernColors(html); + assert.match(out, /rgb\(\d+, \d+, \d+\)/); + assert.doesNotMatch(out, /color-mix\(|oklch\(/); +}); + +test("turns Tailwind-style rgb channels on custom props into comma form", () => { + const html = `:root{--c: 37 99 235;} .bar{background:rgb(var(--c) / 50%)}`; + const out = downlevelModernColors(html); + assert.match(out, /--c: 37, 99, 235;/); + assert.match(out, /rgba\(var\(--c\), 0\.5\)/); +}); + +test("leaves already-legacy rgb() alone", () => { + const html = ``; + assert.equal(downlevelModernColors(html), html); +}); + +test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler", () => { + const html = ``; + const out = prepareGeneratedHtml(html); + assert.match(out, /data-df-legacy-charts="1"/); + assert.match(out, /getElementsByTagName\("svg"\)/); + assert.match(out, /getElementsByTagName\("canvas"\)/); + assert.doesNotMatch(out, /\?\.|=>|`/); +}); diff --git a/frontend-web/src/core/utils/legacy-html-preview.ts b/frontend-web/src/core/utils/legacy-html-preview.ts new file mode 100644 index 00000000..bcdf8089 --- /dev/null +++ b/frontend-web/src/core/utils/legacy-html-preview.ts @@ -0,0 +1,491 @@ +/** + * Prepare generated HTML so pie/bar/line charts still paint on Chrome 100–109 + * (the Win7-era baseline this app targets). + * + * Model-written pages often copy modern design tokens: `oklch()`, `color-mix()`, + * and space-separated `rgb(r g b / a)`. Those declarations are ignored on + * Chrome 109, so SVG/CSS/canvas fills become transparent and the chart looks + * blank even though the markup is there. This rewrite happens in the parent + * before the blob iframe loads; the injected shim is ES5. + */ + +import { injectInPageAnchorHandler } from "./markdown"; + +const MAX_PASSES = 12; + +export function prepareGeneratedHtml(html: string): string { + return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? ""))); +} + +export function downlevelModernColors(html: string): string { + let current = rewriteSpaceSeparatedCustomProps(html); + current = rewriteVarAlphaRgb(current); + let previous = ""; + for (let pass = 0; pass < MAX_PASSES && current !== previous; pass += 1) { + previous = current; + current = replaceFunctionCalls(current, "oklch", convertOklch); + current = replaceFunctionCalls(current, "oklab", convertOklab); + current = replaceFunctionCalls(current, "lch", convertLch); + current = replaceFunctionCalls(current, "lab", convertLab); + current = replaceFunctionCalls(current, "rgba", convertModernRgb); + current = replaceFunctionCalls(current, "rgb", convertModernRgb); + current = replaceFunctionCalls(current, "hsla", convertModernHsl); + current = replaceFunctionCalls(current, "hsl", convertModernHsl); + current = replaceFunctionCalls(current, "color-mix", convertColorMix); + } + return current; +} + +function injectLegacyChartShim(html: string): string { + if (html.includes("data-df-legacy-charts")) return html; + const script = ``; + if (/<\/body>/i.test(html)) { + return html.replace(/<\/body>/i, `${script}`); + } + return html + script; +} + +function rewriteSpaceSeparatedCustomProps(html: string): string { + return html.replace( + /(--[\w-]+)\s*:\s*(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})\s*;/g, + "$1: $2, $3, $4;", + ); +} + +function rewriteVarAlphaRgb(html: string): string { + return html.replace( + /\brgba?\(\s*var\((--[\w-]+)\)\s*\/\s*([^)]+?)\)/gi, + (_, name: string, alpha: string) => `rgba(var(${name}), ${normalizeAlpha(alpha.trim())})`, + ); +} + +function replaceFunctionCalls( + source: string, + name: string, + replace: (args: string) => string | null, +): string { + const lower = source.toLowerCase(); + const token = `${name.toLowerCase()}(`; + let output = ""; + let cursor = 0; + while (cursor < source.length) { + const index = lower.indexOf(token, cursor); + if (index < 0) { + output += source.slice(cursor); + break; + } + const before = index === 0 ? "" : source[index - 1]!; + if (index > 0 && /[A-Za-z0-9_-]/.test(before)) { + output += source.slice(cursor, index + 1); + cursor = index + 1; + continue; + } + const open = index + name.length; + const close = matchClosingParen(source, open); + if (close < 0) { + output += source.slice(cursor); + break; + } + const args = source.slice(open + 1, close); + const next = replace(args); + output += source.slice(cursor, index) + (next ?? source.slice(index, close + 1)); + cursor = close + 1; + } + return output; +} + +function matchClosingParen(source: string, openIndex: number): number { + let depth = 0; + let quote: string | null = null; + for (let i = openIndex; i < source.length; i += 1) { + const char = source[i]!; + if (quote) { + if (char === "\\") { + i += 1; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === "(") depth += 1; + else if (char === ")") { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +function convertOklch(args: string): string | null { + if (/\bfrom\b/i.test(args)) return null; + const { values, alpha } = splitSlashAlpha(args); + if (values.length < 3) return null; + const L = parseLightness(values[0]!); + const C = parseNumber(values[1]!); + const H = parseHue(values[2]!); + if (L == null || C == null || H == null) return null; + const [a, b] = polarToLab(C, H); + return formatRgba(oklabToSrgb(L, a, b), alpha); +} + +function convertOklab(args: string): string | null { + if (/\bfrom\b/i.test(args)) return null; + const { values, alpha } = splitSlashAlpha(args); + if (values.length < 3) return null; + const L = parseLightness(values[0]!); + const a = parseNumber(values[1]!); + const b = parseNumber(values[2]!); + if (L == null || a == null || b == null) return null; + return formatRgba(oklabToSrgb(L, a, b), alpha); +} + +function convertLch(args: string): string | null { + if (/\bfrom\b/i.test(args)) return null; + const { values, alpha } = splitSlashAlpha(args); + if (values.length < 3) return null; + const L = parsePercentOrNumber(values[0]!, 100); + const C = parseNumber(values[1]!); + const H = parseHue(values[2]!); + if (L == null || C == null || H == null) return null; + const [a, b] = polarToLab(C, H); + return formatRgba(labToSrgb(L, a, b), alpha); +} + +function convertLab(args: string): string | null { + if (/\bfrom\b/i.test(args)) return null; + const { values, alpha } = splitSlashAlpha(args); + if (values.length < 3) return null; + const L = parsePercentOrNumber(values[0]!, 100); + const a = parseNumber(values[1]!); + const b = parseNumber(values[2]!); + if (L == null || a == null || b == null) return null; + return formatRgba(labToSrgb(L, a, b), alpha); +} + +function convertColorMix(args: string): string | null { + const parts = splitTopLevel(args, ","); + if (parts.length < 2) return null; + const colorParts = parts.slice(1); + const left = parseMixStop(colorParts[0] ?? ""); + const right = parseMixStop(colorParts[1] ?? "transparent"); + if (!left.color && !right.color) return null; + const leftPct = left.percent ?? (right.percent == null ? 50 : 100 - right.percent); + const rightPct = right.percent ?? 100 - leftPct; + const sum = leftPct + rightPct || 1; + const leftRgb = resolveMixColor(left.color) ?? [0, 0, 0, 0]; + const rightRgb = resolveMixColor(right.color) ?? [0, 0, 0, 0]; + const t = rightPct / sum; + return formatRgba( + [ + leftRgb[0] + (rightRgb[0] - leftRgb[0]) * t, + leftRgb[1] + (rightRgb[1] - leftRgb[1]) * t, + leftRgb[2] + (rightRgb[2] - leftRgb[2]) * t, + ], + leftRgb[3] + (rightRgb[3] - leftRgb[3]) * t, + ); +} + +function convertModernRgb(args: string): string | null { + if (args.includes("var(") || /\bfrom\b/i.test(args)) return null; + if (args.includes(",") && !args.includes("/")) return null; + const { values, alpha } = splitSlashAlpha(args); + if (values.length < 3) return null; + const r = parseRgbChannel(values[0]!); + const g = parseRgbChannel(values[1]!); + const b = parseRgbChannel(values[2]!); + if (r == null || g == null || b == null) return null; + return formatRgba([r, g, b], alpha); +} + +function convertModernHsl(args: string): string | null { + if (args.includes("var(") || /\bfrom\b/i.test(args)) return null; + if (args.includes(",") && !args.includes("/")) return null; + const { values, alpha } = splitSlashAlpha(args); + if (values.length < 3) return null; + const h = parseHue(values[0]!); + const s = parsePercentOrNumber(values[1]!, 1); + const l = parsePercentOrNumber(values[2]!, 1); + if (h == null || s == null || l == null) return null; + return formatRgba(hslToSrgb(h, s, l), alpha); +} + +function parseMixStop(raw: string): { color: string; percent: number | null } { + const trimmed = raw.trim(); + const match = trimmed.match(/^(.*?)(?:\s+(-?[\d.]+%))?\s*$/); + const color = (match?.[1] ?? trimmed).trim(); + const percent = match?.[2] ? Number.parseFloat(match[2]) : null; + return { color, percent: Number.isFinite(percent) ? percent : null }; +} + +function resolveMixColor(value: string): [number, number, number, number] | null { + const text = value.trim(); + if (!text || /^var\(/i.test(text)) return null; + if (/^transparent$/i.test(text)) return [0, 0, 0, 0]; + const hex = parseHex(text); + if (hex) return hex; + const rgbMatch = text.match(/^rgba?\((.+)\)$/i); + if (rgbMatch) { + const converted = convertModernRgb(rgbMatch[1]!) ?? (/,\s*/.test(rgbMatch[1]!) ? `rgba(${rgbMatch[1]})` : null); + if (converted) { + const inner = converted.match(/^rgba?\((.+)\)$/i); + if (inner) { + const bits = inner[1]!.split(",").map((part) => Number.parseFloat(part.trim())); + if (bits.length >= 3 && bits.every((n) => Number.isFinite(n))) { + return [bits[0]!, bits[1]!, bits[2]!, bits[3] ?? 1]; + } + } + } + } + return NAMED[text.toLowerCase()] ?? null; +} + +function splitSlashAlpha(args: string): { values: string[]; alpha: number } { + const pieces = splitTopLevel(args, "/"); + const values = tokenizeCssList(pieces[0] ?? ""); + const alpha = pieces.length > 1 ? normalizeAlpha(pieces[1]!.trim()) : 1; + return { values, alpha }; +} + +function splitTopLevel(source: string, separator: string): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < source.length; i += 1) { + const char = source[i]!; + if (quote) { + current += char; + if (char === "\\" && i + 1 < source.length) { + current += source[i + 1]; + i += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (depth === 0 && char === separator) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + if (current.trim()) parts.push(current); + return parts; +} + +function tokenizeCssList(source: string): string[] { + return source + .trim() + .split(/[\s,]+/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function parseLightness(raw: string): number | null { + const value = parseNumber(raw); + if (value == null) return null; + if (/%$/.test(raw.trim()) || value > 1) return clamp(value / 100, 0, 1); + return clamp(value, 0, 1); +} + +function parsePercentOrNumber(raw: string, percentBase: number): number | null { + const value = parseNumber(raw); + if (value == null) return null; + if (/%$/.test(raw.trim())) return value / 100 * (percentBase === 100 ? 100 : 1); + if (percentBase === 100 && value <= 1) return value * 100; + return value; +} + +function parseRgbChannel(raw: string): number | null { + const value = parseNumber(raw); + if (value == null) return null; + if (/%$/.test(raw.trim())) return clamp(value * 2.55, 0, 255); + if (value >= 0 && value <= 1 && /0?\.\d+/.test(raw.trim())) return clamp(value * 255, 0, 255); + return clamp(value, 0, 255); +} + +function parseHue(raw: string): number | null { + const text = raw.trim().toLowerCase(); + const value = parseNumber(text); + if (value == null) return null; + if (text.endsWith("rad")) return (value * 180) / Math.PI; + if (text.endsWith("turn")) return value * 360; + return value; +} + +function parseNumber(raw: string): number | null { + const value = Number.parseFloat(raw.trim().replace(/deg|rad|turn|%/gi, "")); + return Number.isFinite(value) ? value : null; +} + +function normalizeAlpha(raw: string): number { + const value = parseNumber(raw); + if (value == null) return 1; + if (/%$/.test(raw.trim()) || value > 1) return clamp(value / 100, 0, 1); + return clamp(value, 0, 1); +} + +function parseHex(value: string): [number, number, number, number] | null { + const hex = value.trim(); + const short = /^#([\da-f]{3})$/i.exec(hex); + if (short) { + const [r, g, b] = short[1]!.split("").map((ch) => Number.parseInt(ch + ch, 16)); + return [r!, g!, b!, 1]; + } + const long = /^#([\da-f]{6})([\da-f]{2})?$/i.exec(hex); + if (!long) return null; + const n = Number.parseInt(long[1]!, 16); + const alpha = long[2] ? Number.parseInt(long[2], 16) / 255 : 1; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255, alpha]; +} + +function polarToLab(c: number, hueDeg: number): [number, number] { + const hue = (hueDeg * Math.PI) / 180; + return [c * Math.cos(hue), c * Math.sin(hue)]; +} + +function oklabToSrgb(L: number, a: number, b: number): [number, number, number] { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.291485548 * b; + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + return linearToSrgb([ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]); +} + +function labToSrgb(L: number, a: number, b: number): [number, number, number] { + const y = (L + 16) / 116; + const x = a / 500 + y; + const z = y - b / 200; + const xn = 0.95047; + const yn = 1; + const zn = 1.08883; + const [X, Y, Z] = [xn * labFn(x), yn * labFn(y), zn * labFn(z)]; + return linearToSrgb([ + 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z, + -0.969266 * X + 1.8760108 * Y + 0.041556 * Z, + 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z, + ]); +} + +function labFn(t: number): number { + const d = 6 / 29; + return t > d ? t * t * t : 3 * d * d * (t - 4 / 29); +} + +function hslToSrgb(h: number, s: number, l: number): [number, number, number] { + const sat = clamp(s, 0, 1); + const light = clamp(l, 0, 1); + const c = (1 - Math.abs(2 * light - 1)) * sat; + const hp = (((h % 360) + 360) % 360) / 60; + const x = c * (1 - Math.abs((hp % 2) - 1)); + let r = 0; + let g = 0; + let b = 0; + if (hp >= 0 && hp < 1) [r, g, b] = [c, x, 0]; + else if (hp < 2) [r, g, b] = [x, c, 0]; + else if (hp < 3) [r, g, b] = [0, c, x]; + else if (hp < 4) [r, g, b] = [0, x, c]; + else if (hp < 5) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const m = light - c / 2; + return [(r + m) * 255, (g + m) * 255, (b + m) * 255]; +} + +function linearToSrgb(channels: [number, number, number]): [number, number, number] { + return channels.map((channel) => { + const c = clamp(channel, 0, 1); + const gamma = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; + return gamma * 255; + }) as [number, number, number]; +} + +function formatRgba(rgb: [number, number, number], alpha: number): string { + const r = Math.round(clamp(rgb[0], 0, 255)); + const g = Math.round(clamp(rgb[1], 0, 255)); + const b = Math.round(clamp(rgb[2], 0, 255)); + const a = clamp(alpha, 0, 1); + if (a >= 0.999) return `rgb(${r}, ${g}, ${b})`; + return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +const NAMED: Record = { + black: [0, 0, 0, 1], + white: [255, 255, 255, 1], + red: [255, 0, 0, 1], + green: [0, 128, 0, 1], + blue: [0, 0, 255, 1], + transparent: [0, 0, 0, 0], +};