Merge branch 'master' into master-zl

This commit is contained in:
吉鑫升 committed 2026-08-31 18:06:54 +08:00
commit ca2696b23f
16 files changed
+922 -179

No files matched your search

+1 -1
View File
@@ -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-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** 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`. - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** 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/`。 - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 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 等侧栏宽度过渡稳定后再测量高亮框。 - **简洁模式用户引导 (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.<stage>.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:<stage>` 窗口事件强制重放(忽略已看过记忆)。 - **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.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:<stage>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (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. - **敏感词管理 (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/`。 - **技能地址管理 (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`. - **按钮管理 (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`.
@@ -31,6 +31,16 @@ const requiredLegacyFallbacks = [
pattern: /<InputGroup className="h-auto min-h-16">/, pattern: /<InputGroup className="h-auto min-h-16">/,
description: "Prompt textarea must not rely on :has() for its parent height", 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",
},
{
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) { for (const file of cssFiles) {
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; 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. * Sandboxed preview of a generated HTML document.
@@ -26,7 +26,7 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>(); const [url, setUrl] = useState<string>();
useEffect(() => { useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], { const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
@@ -615,7 +615,11 @@ export function AgentGallery() {
</TabsContent> </TabsContent>
)} )}
</Tabs> </Tabs>
<AgentGuide stage="list" eligible={isAgentGuideEnvironment()} /> <AgentGuide
stage="list"
eligible={isAgentGuideEnvironment()}
autoStart={false}
/>
</div> </div>
); );
} }
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Guide, type GuideStep } from "tdesign-react"; import { Guide, type GuideStep } from "tdesign-react";
import { env } from "@/env"; import { env } from "@/env";
@@ -13,7 +12,8 @@ import { isWujieEnv } from "@/core/embed/wujie";
* 「浏览管理 → 创建与配置 → 发起问答」流程: * 「浏览管理 → 创建与配置 → 发起问答」流程:
* *
* - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、 * - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、
* 创建入口;最后一步「去创建智能体 →」直接跳到创建页衔接下一段。 * 创建入口;完成 / 跳过就地结束,不跳转创建页(创建页有自己的一段
* 引导,点「创建智能体」进入时自动演示)。
* - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、 * - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、
* 知识空间、SOUL.md、技能、保存。 * 知识空间、SOUL.md、技能、保存。
* - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与 * - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与
@@ -23,6 +23,10 @@ import { isWujieEnv } from "@/core/embed/wujie";
* localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开 * localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开
* 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。 * 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。
* *
* 自动演示范围:只有 create 段(进入新建页)与 chat 段自动弹;list 段挂在
* 智能体列表页但不自动演示(`autoStart={false}`,避免一进列表页就弹引导),
* 仅由头部「使用引导」按钮手动触发。
*
* 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤 * 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤
* (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的 * (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的
* 目标元素会抛错,不能盲传。 * 目标元素会抛错,不能盲传。
@@ -121,7 +125,7 @@ const LIST_STEPS: StepSpec[] = [
mode: "popup", mode: "popup",
placement: "bottom-left", placement: "bottom-left",
title: "创建智能体", title: "创建智能体",
body: "准备好了就点这里,开始创建你的专属智能体。点「去创建智能体」直接进入下一站。", body: "准备好了就点这里,开始创建你的专属智能体;进入创建页后还会有一段配置引导。",
}, },
]; ];
@@ -260,17 +264,19 @@ function buildSteps(stage: AgentGuideStage): GuideStep[] {
} }
/** /**
* 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为false 时完全 * 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为 false 时完全
* 静默(不监听、不自动弹)。挂在三个页面各自的作用域内。 * 静默(不监听、不自动弹)。挂在三个页面各自的作用域内;`autoStart` 为 false
* 时仅响应手动触发(列表页不自动弹,进新建页才自动演示)。
*/ */
export function AgentGuide({ export function AgentGuide({
stage, stage,
eligible, eligible,
autoStart = true,
}: { }: {
stage: AgentGuideStage; stage: AgentGuideStage;
eligible: boolean; eligible: boolean;
autoStart?: boolean;
}) { }) {
const router = useRouter();
const [current, setCurrent] = useState(-1); const [current, setCurrent] = useState(-1);
const stepsRef = useRef<GuideStep[]>([]); const stepsRef = useRef<GuideStep[]>([]);
@@ -285,7 +291,7 @@ export function AgentGuide({
// 「已看过」只由 finish 回调(完成/跳过)写入。 // 「已看过」只由 finish 回调(完成/跳过)写入。
const autoTriedRef = useRef(false); const autoTriedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!eligible || autoTriedRef.current) return; if (!eligible || !autoStart || autoTriedRef.current) return;
autoTriedRef.current = true; autoTriedRef.current = true;
if (isStageFinished(stage)) return; if (isStageFinished(stage)) return;
const timer = setTimeout(start, START_DELAY_MS); const timer = setTimeout(start, START_DELAY_MS);
@@ -294,7 +300,7 @@ export function AgentGuide({
// 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。 // 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。
autoTriedRef.current = false; autoTriedRef.current = false;
}; };
}, [eligible, stage, start]); }, [eligible, autoStart, stage, start]);
// 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。 // 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。
useEffect(() => { useEffect(() => {
@@ -305,15 +311,11 @@ export function AgentGuide({
window.removeEventListener(startEventName(stage), handler); window.removeEventListener(startEventName(stage), handler);
}, [eligible, stage, start]); }, [eligible, stage, start]);
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」。 // 完成 / 跳过都是用户显式结束引导,就地关闭并记忆「已看过」,不跳转任何页面。
// list 阶段的最后一步按钮文案是「去创建智能体 →」,完成即跳转衔接下一段。
const finish = useCallback(() => { const finish = useCallback(() => {
markStageFinished(stage); markStageFinished(stage);
setCurrent(-1); setCurrent(-1);
if (stage === "list") { }, [stage]);
router.push("/page/workspace/agents/new");
}
}, [stage, router]);
if (!eligible) return null; if (!eligible) return null;
@@ -324,9 +326,6 @@ export function AgentGuide({
onChange={setCurrent} onChange={setCurrent}
onFinish={finish} onFinish={finish}
onSkip={finish} onSkip={finish}
finishButtonProps={
stage === "list" ? { content: "去创建智能体 →" } : undefined
}
/> />
); );
} }
@@ -97,9 +97,9 @@ import {
openMermaidSvgInNewTab, openMermaidSvgInNewTab,
renderMermaidToSvg, renderMermaidToSvg,
} from "@/core/utils/mermaid"; } from "@/core/utils/mermaid";
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
import { import {
createMarkdownAnchorResolver, createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor, isInPageAnchor,
scrollToInPageAnchor, scrollToInPageAnchor,
} from "@/core/utils/markdown"; } from "@/core/utils/markdown";
@@ -1422,7 +1422,9 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开"); toast.error("文件内容为空,暂时无法在新窗口打开");
return; 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); const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000); window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
@@ -1460,7 +1462,7 @@ export function ArtifactFileDetail({
return ( return (
<Artifact className={cn(className)}> <Artifact className={cn(className)}>
<ArtifactHeader className="relative flex flex-wrap items-center gap-3 px-4 py-3 pr-14"> <ArtifactHeader className="relative flex flex-wrap items-center gap-3 px-4 py-3 pr-14">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div <div
className={cn( className={cn(
@@ -1554,8 +1556,8 @@ export function ArtifactFileDetail({
</div> </div>
)} )}
</div> </div>
<div className="flex min-w-0 w-full items-center justify-end gap-2 md:ml-auto md:w-auto"> <div className="flex min-w-0 w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 min-w-0 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8"> <ArtifactActions className="bg-muted/45 min-w-0 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && ( {canPublishToKnowledge && (
<ArtifactAction <ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon} icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
@@ -1683,18 +1685,18 @@ export function ArtifactFileDetail({
} }
}} }}
/> />
</ArtifactActions> </ArtifactActions>
</div> </div>
{/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的 {/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的
动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */} 动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */}
<ArtifactAction <ArtifactAction
className="bg-background/90 hover:bg-accent absolute top-3 right-3 z-10 border shadow-sm" className="bg-background/90 hover:bg-accent absolute top-3 right-3 z-10 border shadow-sm"
icon={XIcon} icon={XIcon}
label="收起文件面板" label="收起文件面板"
onClick={() => (onClose ? onClose() : setOpen(false))} onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip="收起文件面板" tooltip="收起文件面板"
/> />
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent <ArtifactContent
ref={artifactContentRef} ref={artifactContentRef}
className="relative p-0" className="relative p-0"
@@ -2084,7 +2086,7 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead // Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame). // 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", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -143,6 +143,7 @@ export function MarkdownContent({
className={className} className={className}
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components} components={components}
> >
{content} {content}
@@ -5,22 +5,27 @@ import remarkMath from "remark-math";
import type { StreamdownProps } from "streamdown"; import type { StreamdownProps } from "streamdown";
import { rehypeSplitWordsIntoSpans } from "../rehype"; import { rehypeSplitWordsIntoSpans } from "../rehype";
import { SANDBOX_MERMAID_CONFIG } from "../utils/mermaid";
import { remarkSanitizeMermaid } from "./remark-sanitize-mermaid";
export const streamdownPlugins = { export const streamdownPlugins = {
remarkPlugins: [ remarkPlugins: [
remarkGfm, remarkGfm,
[remarkMath, { singleDollarTextMath: true }], [remarkMath, { singleDollarTextMath: true }],
remarkSanitizeMermaid,
] as StreamdownProps["remarkPlugins"], ] as StreamdownProps["remarkPlugins"],
rehypePlugins: [ rehypePlugins: [
rehypeRaw, rehypeRaw,
[rehypeKatex, { output: "html" }], [rehypeKatex, { output: "html" }],
] as StreamdownProps["rehypePlugins"], ] as StreamdownProps["rehypePlugins"],
mermaidConfig: SANDBOX_MERMAID_CONFIG,
}; };
export const streamdownPluginsWithWordAnimation = { export const streamdownPluginsWithWordAnimation = {
remarkPlugins: [ remarkPlugins: [
remarkGfm, remarkGfm,
[remarkMath, { singleDollarTextMath: true }], [remarkMath, { singleDollarTextMath: true }],
remarkSanitizeMermaid,
] as StreamdownProps["remarkPlugins"], ] as StreamdownProps["remarkPlugins"],
rehypePlugins: [ rehypePlugins: [
[rehypeKatex, { output: "html" }], [rehypeKatex, { output: "html" }],
@@ -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);
});
};
}
+94 -135
View File
@@ -36,6 +36,7 @@ import {
import { import {
collectHumanMessageIds, collectHumanMessageIds,
hasServerEchoedOptimisticHuman, hasServerEchoedOptimisticHuman,
isStaleStreamFinish,
mergeStreamDisplayValues, mergeStreamDisplayValues,
shouldDiscardTerminalOverlay, shouldDiscardTerminalOverlay,
} from "./stream-display"; } from "./stream-display";
@@ -195,11 +196,9 @@ function getRunMetadataStorage(): {
/** /**
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}`` * SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
* but **deletes that key on stream onSuccess**. A premature SSE close * but **deletes that key on stream onSuccess**. We keep a second key the SDK
* (proxy timeout / false-positive disconnect) looks like success, so the * never touches so a 409 / dropped-SSE exception can still rejoin. Normal
* reconnect key vanishes while the backend run is still occupying the * SSE close clears it immediately — do not wait for ``runs.get``.
* thread. We keep a second key the SDK never touches and only clear it
* after ``runs.get`` confirms a terminal status.
*/ */
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:"; const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
@@ -281,36 +280,18 @@ export async function stopThreadRun(
/** /**
* 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run * 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run
* 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。 * 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。
*
* 不在发送前 GET /runs —— 协助卡出现时图多半已经结束,为兜底去查会让每次
* 点选项都卡住。未在流式则直接发;真遇到 409 再走 confirmOrRejoin。
*/ */
export async function stopActiveThreadBeforeClarificationReply( export async function stopActiveThreadBeforeClarificationReply(
threadId: string | null | undefined, threadId: string | null | undefined,
thread: { isLoading?: boolean; stop: () => void | Promise<void> }, thread: { isLoading?: boolean; stop: () => void | Promise<void> },
): Promise<void> { ): Promise<void> {
let storedRunId: string | null = null; if (!thread.isLoading) {
if (threadId && typeof window !== "undefined") { if (threadId) clearActiveRunId(threadId);
try { return;
storedRunId =
normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
) || readActiveRunId(threadId);
} catch {
storedRunId = null;
}
} }
// 协助卡出现时图已经 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 { try {
await stopThreadRun(threadId); await stopThreadRun(threadId);
} catch { } catch {
@@ -325,7 +306,7 @@ export async function stopActiveThreadBeforeClarificationReply(
await new Promise((resolve) => setTimeout(resolve, 120)); await new Promise((resolve) => setTimeout(resolve, 120));
} }
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) { if (typeof error === "string" && error.trim()) {
return error; return error;
} }
@@ -345,21 +326,21 @@ function getStreamErrorMessage(error: unknown): string {
return nestedError; return nestedError;
} }
} }
return "Request failed."; return "Request failed.";
} }
/** /**
* React production error #185 is the minified form of "Maximum update depth * 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, * 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. * so it must never be surfaced as the answer's stream error.
*/ */
function isReactMaximumUpdateDepthError(message: string): boolean { function isReactMaximumUpdateDepthError(message: string): boolean {
return ( return (
/maximum update depth exceeded/i.test(message) || /maximum update depth exceeded/i.test(message) ||
/minified react error\s*#185\b/i.test(message) || /minified react error\s*#185\b/i.test(message) ||
/react\.dev\/errors\/185\b/i.test(message) /react\.dev\/errors\/185\b/i.test(message)
); );
} }
const TERMINAL_RUN_STATUSES = new Set([ const TERMINAL_RUN_STATUSES = new Set([
"success", "success",
@@ -368,12 +349,9 @@ const TERMINAL_RUN_STATUSES = new Set([
"interrupted", "interrupted",
]); ]);
// A completed server run normally sends a terminal SSE event. In local // Only probe /runs after an *exception* path has already set streamHoldOpen
// development, however, a Vite hot update, a proxy disconnect, or a brief // (dropped SSE, 409, React #185). Normal Q&A must not GET /runs on finish or
// network interruption can make the browser miss that final event while the // every 5s — that made every send wait for a status round-trip.
// 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.
const STREAM_RECONCILIATION_IDLE_MS = 12_000; const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000; const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call / slow model write is // A run can legitimately be quiet while a long tool call / slow model write is
@@ -616,14 +594,9 @@ export function useThreadStream({
"terminal" | "rejoined" | "unknown" "terminal" | "rejoined" | "unknown"
> >
>(async () => "unknown"); >(async () => "unknown");
// Keep the send button in "stop" until we confirm the backend run is // Held only on exception recovery (dropped SSE / 409). Normal turns rely
// terminal. A premature SSE close must not look like the turn ended. // on SDK `isLoading` so the send button unlocks as soon as SSE closes.
const [streamHoldOpen, setStreamHoldOpen] = useState( const [streamHoldOpen, setStreamHoldOpen] = useState(false);
() =>
Boolean(
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
),
);
const sendInFlightRef = useRef(false); const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0); const sendEpochRef = useRef(0);
// Filled after useStream; onFinish/onError may read it asynchronously. // Filled after useStream; onFinish/onError may read it asynchronously.
@@ -695,17 +668,17 @@ export function useThreadStream({
runMetadataStorageRef.current = getRunMetadataStorage(); runMetadataStorageRef.current = getRunMetadataStorage();
} }
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock), client: getAPIClient(isMock),
assistantId: assistantId ?? "lead_agent", assistantId: assistantId ?? "lead_agent",
threadId: onStreamThreadId, threadId: onStreamThreadId,
// The SDK's current implementation falls back to synchronous external- // The SDK's current implementation falls back to synchronous external-
// store notifications when this option is omitted. A network chunk can // store notifications when this option is omitted. A network chunk can
// contain dozens of token events, causing nested React renders and the // contain dozens of token events, causing nested React renders and the
// intermittent production-only React #185 error. Coalesce notifications // intermittent production-only React #185 error. Coalesce notifications
// to one visual update per frame; stream data itself is not dropped. // to one visual update per frame; stream data itself is not dropped.
throttle: 16, throttle: 16,
reconnectOnMount: runMetadataStorageRef.current reconnectOnMount: runMetadataStorageRef.current
? () => runMetadataStorageRef.current! ? () => runMetadataStorageRef.current!
: false, : false,
fetchStateHistory: { limit: 1 }, fetchStateHistory: { limit: 1 },
@@ -715,7 +688,6 @@ export function useThreadStream({
if (meta.run_id) { if (meta.run_id) {
activeRunIdRef.current = meta.run_id; activeRunIdRef.current = meta.run_id;
persistActiveRunId(meta.thread_id, meta.run_id); persistActiveRunId(meta.thread_id, meta.run_id);
setStreamHoldOpen(true);
} }
const metadataAgentId = context.agent_id ?? context.agent_name; const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata = const extraMetadata =
@@ -870,21 +842,21 @@ export function useThreadStream({
toast(e.message); toast(e.message);
} }
}, },
onError(error) { onError(error) {
markStreamActivity(); markStreamActivity();
const message = getStreamErrorMessage(error); const message = getStreamErrorMessage(error);
// A synchronous SDK store notification used to let React's update-depth // A synchronous SDK store notification used to let React's update-depth
// guard escape into the stream promise. Do not present that internal UI // 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 // exception as a model failure. Reconcile/rejoin on the next task so the
// backend run can continue and the current answer is preserved. // backend run can continue and the current answer is preserved.
if (isReactMaximumUpdateDepthError(message)) { if (isReactMaximumUpdateDepthError(message)) {
window.setTimeout(() => { window.setTimeout(() => {
setStreamHoldOpen(true); setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error"); void confirmOrRejoinRef.current("error");
}, 0); }, 0);
return; return;
} }
// Replacing a stalled local SSE reader aborts the in-flight fetch. That // Replacing a stalled local SSE reader aborts the in-flight fetch. That
// is expected and must not look like a user-facing "network" failure. // is expected and must not look like a user-facing "network" failure.
if (isAbortLikeError(error)) { if (isAbortLikeError(error)) {
return; return;
@@ -952,34 +924,30 @@ export function useThreadStream({
} }
toast.error(message); toast.error(message);
}, },
onFinish(state) { onFinish(state, runMeta) {
lastStreamActivityAtRef.current = Date.now(); lastStreamActivityAtRef.current = Date.now();
// Do not trust a clean SSE close as "the run is done". streamResumable // Trust a clean SSE close. Querying /runs here blocked every send for
// chats keep the backend run alive on disconnect; the SDK still fires // ~5s to paper over a rare premature disconnect; that case is handled
// onFinish and would otherwise restore the send button. // when the next send hits 409 (or onError for a dropped stream).
// if (
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮 isStaleStreamFinish({
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would finishingRunId: runMeta?.run_id,
// steal the live SSE and dump tokens only at the end. currentRunId: activeRunIdRef.current,
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { })
) {
return; return;
} }
setStreamHoldOpen(true); const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
void confirmOrRejoinRef.current("finish").then((result) => { if (finThreadId) {
if (result === "rejoined") { clearActiveRunId(finThreadId);
return; finalizeResearchReportStream(finThreadId);
} }
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { activeRunIdRef.current = null;
return; setStreamHoldOpen(false);
} if (state?.values) {
setStreamHoldOpen(false);
// run 真正终结(非 rejoin):若深度研究报告流仍 active(tool_finished
// 帧丢失/被取消),标记中断,避免进度条永久转圈。
const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (finThreadId) finalizeResearchReportStream(finThreadId);
listeners.current.onFinish?.(state.values); listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); }
}); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
}, },
}); });
@@ -1017,14 +985,6 @@ export function useThreadStream({
if (streamReconciliationInFlightRef.current && reason === "poll") { if (streamReconciliationInFlightRef.current && reason === "poll") {
return "unknown"; 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; streamReconciliationInFlightRef.current = true;
const epochAtStart = sendEpochRef.current; const epochAtStart = sendEpochRef.current;
try { try {
@@ -1200,11 +1160,10 @@ export function useThreadStream({
if (dropped && idleFor < 800) { if (dropped && idleFor < 800) {
return; return;
} }
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId); // Do not probe just because a run id is stored. Only the exception
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) { // path sets streamHoldOpen; polling otherwise made every turn wait
return; // on GET /runs before the user could send again.
} if (!streamHoldOpen) {
if (!stored && !streamHoldOpen) {
return; return;
} }
await confirmOrRejoin("poll"); await confirmOrRejoin("poll");
@@ -1723,20 +1682,20 @@ export function useThreadStream({
thread.isLoading, thread.isLoading,
) ?? thread.values; ) ?? thread.values;
const mergedMessages = const mergedMessages =
Array.isArray(mergedValues?.messages) Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[]) ? (mergedValues.messages as Message[])
: thread.messages; : thread.messages;
const visibleThreadError = const visibleThreadError =
thread.error && thread.error &&
isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error))
? undefined ? undefined
: thread.error; : thread.error;
const mergedThread = { const mergedThread = {
...thread, ...thread,
error: visibleThreadError, error: visibleThreadError,
values: mergedValues, values: mergedValues,
messages: messages:
optimisticMessages.length > 0 optimisticMessages.length > 0
@@ -5,6 +5,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { import {
collectHumanMessageIds, collectHumanMessageIds,
hasServerEchoedOptimisticHuman, hasServerEchoedOptimisticHuman,
isStaleStreamFinish,
mergeStreamDisplayValues, mergeStreamDisplayValues,
shouldDiscardTerminalOverlay, shouldDiscardTerminalOverlay,
} from "./stream-display"; } from "./stream-display";
@@ -28,6 +29,30 @@ test("live streaming ignores frozen checkpoint messages so tokens can paint", ()
assert.deepEqual(afterStream?.messages, overrides.messages); 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", () => { test("a later send discards the previous run's getState overlay", () => {
assert.equal( assert.equal(
shouldDiscardTerminalOverlay({ shouldDiscardTerminalOverlay({
@@ -51,6 +51,25 @@ export function shouldDiscardTerminalOverlay(opts: {
return false; 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( export function collectHumanMessageIds(
...groups: Array<Message[] | undefined> ...groups: Array<Message[] | undefined>
): Set<string> { ): Set<string> {
@@ -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 = `<svg><path fill="oklch(0.7 0.15 40)"/></svg>`;
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 = `<rect fill="rgb(16, 185, 129)"/>`;
assert.equal(downlevelModernColors(html), html);
});
test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler", () => {
const html = `<!doctype html><html><body><svg viewBox="0 0 100 80"></svg></body></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, /\?\.|=>|`/);
});
@@ -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 = `<script data-df-legacy-charts="1">(function(){
function viewBox(svg){
var vb=svg.getAttribute("viewBox")||svg.getAttribute("viewbox");
if(!vb)return null;
var p=vb.replace(/,/g," ").replace(/^\\s+|\\s+$/g,"").split(/\\s+/);
if(p.length<4)return null;
var w=parseFloat(p[2]), h=parseFloat(p[3]);
if(!(w>0&&h>0))return null;
return {w:w,h:h};
}
function fixSvg(svg){
var box=svg.getBoundingClientRect();
if(box.height>=8&&box.width>=8)return;
var vb=viewBox(svg);
if(!vb)return;
if(!svg.getAttribute("width"))svg.setAttribute("width",String(vb.w));
if(!svg.getAttribute("height"))svg.setAttribute("height",String(vb.h));
svg.style.maxWidth="100%";
svg.style.height="auto";
svg.style.display="block";
}
function fixCanvas(c){
var box=c.getBoundingClientRect();
var parent=c.parentElement;
var pw=parent?parent.clientWidth:0;
var ph=parent?parent.clientHeight:0;
if(c.width<8||box.width<8){
c.width=Math.max(pw,320);
c.style.width="100%";
}
if(c.height<8||box.height<8){
c.height=Math.max(ph,220);
c.style.height=c.height+"px";
}
}
function run(){
var svgs=document.getElementsByTagName("svg");
for(var i=0;i<svgs.length;i++)fixSvg(svgs[i]);
var canvases=document.getElementsByTagName("canvas");
for(var j=0;j<canvases.length;j++)fixCanvas(canvases[j]);
}
if(document.readyState==="complete"||document.readyState==="interactive"){
setTimeout(run,0);
setTimeout(run,400);
}else if(document.addEventListener){
document.addEventListener("DOMContentLoaded",function(){
run();
setTimeout(run,400);
});
}
if(window.addEventListener)window.addEventListener("load",function(){setTimeout(run,50);});
})();</script>`;
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${script}</body>`);
}
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<string, [number, number, number, number]> = {
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],
};
@@ -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/);
});
+96 -5
View File
@@ -2,6 +2,100 @@ import { getFileName } from "./files";
let mermaidInitialized = false; 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 { export function isMermaidFile(filepath: string): boolean {
return /\.mmd$/i.test(filepath); return /\.mmd$/i.test(filepath);
} }
@@ -14,17 +108,14 @@ async function getMermaidModule() {
const mod = await import("mermaid"); const mod = await import("mermaid");
const mermaid = mod.default; const mermaid = mod.default;
if (!mermaidInitialized) { if (!mermaidInitialized) {
mermaid.initialize({ mermaid.initialize(SANDBOX_MERMAID_CONFIG);
startOnLoad: false,
securityLevel: "strict",
});
mermaidInitialized = true; mermaidInitialized = true;
} }
return mermaid; return mermaid;
} }
export async function renderMermaidToSvg(source: string): Promise<string> { export async function renderMermaidToSvg(source: string): Promise<string> {
const text = source.trim(); const text = sanitizeMermaidSource(source).trim();
if (!text) { if (!text) {
throw new Error("Mermaid 内容为空,无法生成图片"); throw new Error("Mermaid 内容为空,无法生成图片");
} }