From 33b719d46007edfef7e4c4c4f235478a48b64c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 26 Aug 2026 11:09:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../workspace/compact-mode-guide.tsx | 138 ++++++++++++--- .../workspace/custom-prompt-menu.tsx | 39 ++++- .../src/core/embed/embed-session.test.ts | 164 ++++++++++++++++++ frontend-web/src/core/embed/embed-session.ts | 155 +++++++++++++++-- 5 files changed, 456 insertions(+), 42 deletions(-) create mode 100644 frontend-web/src/core/embed/embed-session.test.ts diff --git a/AGENTS.md b/AGENTS.md index d08d33a5..80b77828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (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). **简介模式** left-nav is a separate overlay: `/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`):7 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 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 秒打不开则跳过该步防卡死。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 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:` 窗口事件强制重放(忽略已看过记忆)。 - **敏感词管理 (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/`。 diff --git a/frontend-web/src/components/workspace/compact-mode-guide.tsx b/frontend-web/src/components/workspace/compact-mode-guide.tsx index 19ff8fad..6fdf9a66 100644 --- a/frontend-web/src/components/workspace/compact-mode-guide.tsx +++ b/frontend-web/src/components/workspace/compact-mode-guide.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Guide, type GuideStep } from "tdesign-react"; +import { PROMPT_DIALOG_GUIDE_EVENT } from "@/components/workspace/custom-prompt-menu"; + /** * 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的 * Guide 组件(https://tdesign.tencent.com/react/components/guide)。 @@ -16,7 +18,9 @@ import { Guide, type GuideStep } from "tdesign-react"; * * 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个 * 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的 - * 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。 + * 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词 + * 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件), + * 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。 */ const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished"; @@ -32,6 +36,8 @@ const TARGET = { settingsMenu: '[data-compact-guide="settings-menu"]', themeSwitcher: '[data-compact-guide="theme-switcher"]', layoutMode: '[data-compact-guide="layout-mode"]', + promptBar: '[data-compact-guide="prompt-bar"]', + promptDialog: '[data-compact-guide="prompt-dialog"]', promptInput: '[data-compact-guide="prompt-input"]', } as const; @@ -39,6 +45,10 @@ const TARGET = { // 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。 const START_DELAY_MS = 800; +// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开, +// 跳过该步避免引导卡死。 +const PROMPT_DIALOG_TIMEOUT_MS = 4000; + function isBrowser(): boolean { return typeof window !== "undefined"; } @@ -70,6 +80,12 @@ function consumeCompactGuideRequest() { interface StepSpec { /** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */ selector?: string; + /** + * 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查 + * 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以 + * 该步可以留在步骤列表里,等锚点真正挂载后再切过去。 + */ + dependsOn?: string; mode: "popup" | "dialog"; placement: NonNullable; title: string; @@ -118,6 +134,21 @@ const STEP_SPECS: StepSpec[] = [ title: "切回全量模式", body: "需要顶部导航栏和全局侧边栏时,从这里随时切换回「全量模式」。", }, + { + selector: TARGET.promptBar, + mode: "popup", + placement: "top", + title: "常用提示词", + body: "输入框上方是常用提示词栏:点击任意提示词即可一键填入输入框,省去重复输入。最右侧的齿轮按钮用来管理这些提示词,点击「下一步」带你看一看。", + }, + { + selector: TARGET.promptDialog, + dependsOn: TARGET.promptBar, + mode: "popup", + placement: "top", + title: "管理常用提示词", + body: "这就是齿轮按钮打开的管理弹窗:填写名称与提示词内容、选择关联的智能体后保存,即可添加自己的常用提示词(与账号绑定、跨设备可用);下方列表中已保存的提示词可随时编辑、删除;顶部开关可让本账号只用自己保存的提示词。", + }, { selector: TARGET.promptInput, mode: "popup", @@ -127,19 +158,6 @@ const STEP_SPECS: StepSpec[] = [ }, ]; -function buildSteps(): GuideStep[] { - if (!isBrowser()) return []; - return STEP_SPECS.filter( - (spec) => !spec.selector || document.querySelector(spec.selector), - ).map((spec) => ({ - element: spec.selector ?? (() => document.body), - mode: spec.mode, - placement: spec.placement, - title: spec.title, - body: spec.body, - })); -} - /** * 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局 * chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态, @@ -153,14 +171,54 @@ export function CompactModeGuide({ autoStart?: boolean; }) { const [current, setCurrent] = useState(-1); + const specsRef = useRef([]); const stepsRef = useRef([]); + const pollTimerRef = useRef(null); + // 引导是否处于「已要求打开并接管提示词管理弹窗」状态(事件已发出 owner:true)。 + const promptDialogTakenRef = useRef(false); + + const clearPollTimer = useCallback(() => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const setPromptDialog = useCallback( + (open: boolean, takeOver: boolean) => { + promptDialogTakenRef.current = takeOver; + window.dispatchEvent( + new CustomEvent(PROMPT_DIALOG_GUIDE_EVENT, { + detail: { open, owner: takeOver }, + }), + ); + }, + [], + ); + + const releasePromptDialog = useCallback(() => { + if (!promptDialogTakenRef.current) return; + setPromptDialog(false, false); + }, [setPromptDialog]); const start = useCallback(() => { - const steps = buildSteps(); - if (steps.length === 0) return; - stepsRef.current = steps; + clearPollTimer(); + releasePromptDialog(); + const specs = STEP_SPECS.filter( + (spec) => + !spec.selector || document.querySelector(spec.dependsOn ?? spec.selector), + ); + if (specs.length === 0) return; + specsRef.current = specs; + stepsRef.current = specs.map((spec) => ({ + element: spec.selector ?? (() => document.body), + mode: spec.mode, + placement: spec.placement, + title: spec.title, + body: spec.body, + })); setCurrent(0); - }, []); + }, [clearPollTimer, releasePromptDialog]); // 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」 // 只由下方 finish 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新 @@ -199,12 +257,52 @@ export function CompactModeGuide({ return () => window.removeEventListener(COMPACT_GUIDE_START_EVENT, handler); }, [eligible, start]); + // 中途卸载(用户离开页面)也要归还弹窗控制权并停止等待。 + useEffect(() => { + return () => { + clearPollTimer(); + releasePromptDialog(); + }; + }, [clearPollTimer, releasePromptDialog]); + + // 受控切步:进入提示词弹窗步骤前先打开弹窗并等锚点挂载(Guide 切步时才解析 + // 元素,元素必须已存在);离开该步 / 跳过 / 完成时收回弹窗。跳过和完成时 + // onChange 也会收到 -1,与 finish 回调各自幂等。 + const goTo = (next: number) => { + const specs = specsRef.current; + clearPollTimer(); + if (next < 0 || next >= specs.length) { + releasePromptDialog(); + setCurrent(-1); + return; + } + if (specs[next].selector === TARGET.promptDialog) { + setPromptDialog(true, true); + const startedAt = Date.now(); + pollTimerRef.current = window.setInterval(() => { + if (document.querySelector(TARGET.promptDialog)) { + clearPollTimer(); + setCurrent(next); + } else if (Date.now() - startedAt > PROMPT_DIALOG_TIMEOUT_MS) { + clearPollTimer(); + releasePromptDialog(); + goTo(next + 1 >= specs.length ? -1 : next + 1); + } + }, 50); + return; + } + releasePromptDialog(); + setCurrent(next); + }; + // 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或 // 离开不记忆,下次进入简洁模式问答页还会再演示。 const finish = useCallback(() => { + clearPollTimer(); + releasePromptDialog(); markCompactGuideDone(); setCurrent(-1); - }, []); + }, [clearPollTimer, releasePromptDialog]); if (!eligible) return null; @@ -212,7 +310,7 @@ export function CompactModeGuide({ diff --git a/frontend-web/src/components/workspace/custom-prompt-menu.tsx b/frontend-web/src/components/workspace/custom-prompt-menu.tsx index d6cd72b7..88e45bc6 100644 --- a/frontend-web/src/components/workspace/custom-prompt-menu.tsx +++ b/frontend-web/src/components/workspace/custom-prompt-menu.tsx @@ -42,6 +42,13 @@ import { import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; +/** + * 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为 + * `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC / + * 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。 + */ +export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog"; + export function CustomPromptBar({ onInsert, className, @@ -56,10 +63,23 @@ export function CustomPromptBar({ (prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId, ); const [manageOpen, setManageOpen] = useState(false); + const guideOwnsDialogRef = useRef(false); const scrollRef = useRef(null); const [canLeft, setCanLeft] = useState(false); const [canRight, setCanRight] = useState(false); + useEffect(() => { + const handler = (event: Event) => { + const detail = ( + event as CustomEvent<{ open?: boolean; owner?: boolean }> + ).detail; + guideOwnsDialogRef.current = Boolean(detail?.owner); + setManageOpen(Boolean(detail?.open)); + }; + window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler); + return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler); + }, []); + const updateArrows = useCallback(() => { const el = scrollRef.current; if (!el) return; @@ -81,7 +101,10 @@ export function CustomPromptBar({ return ( <> -
+
{prompts.length > 0 && ( <> {overflowing && ( @@ -148,7 +171,14 @@ export function CustomPromptBar({
- + { + // 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。 + if (!next && guideOwnsDialogRef.current) return; + setManageOpen(next); + }} + /> ); } @@ -257,7 +287,10 @@ function ManagePromptsDialog({ onOpenChange(next); }} > - + 管理常用提示词 diff --git a/frontend-web/src/core/embed/embed-session.test.ts b/frontend-web/src/core/embed/embed-session.test.ts new file mode 100644 index 00000000..8df2bd63 --- /dev/null +++ b/frontend-web/src/core/embed/embed-session.test.ts @@ -0,0 +1,164 @@ +/** + * embed-session.ts 浏览上下文隔离单测。 + * 运行方式(项目无 vitest): + * npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \ + * --platform=node --outfile=.tmp-tests/embed-session.test.mjs + * node --test .tmp-tests/embed-session.test.mjs + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isChromeHidden, + isEmbedActive, + isIsolatedSession, + readEmbedSession, + syncEmbedSessionFromSearch, +} from "./embed-session"; + +class MemoryStorage { + #map = new Map(); + getItem(key: string): string | null { + return this.#map.has(key) ? this.#map.get(key)! : null; + } + setItem(key: string, value: string): void { + this.#map.set(key, String(value)); + } + removeItem(key: string): void { + this.#map.delete(key); + } +} + +type FakeWindow = { + self: FakeWindow; + top: FakeWindow; + sessionStorage: MemoryStorage; + location: { hash: string; search: string }; + __deerflowTopChromeEmbed?: boolean; +}; + +const originalWindow = (globalThis as { window?: unknown }).window; + +function withSharedTab(run: (ctx: { + storage: MemoryStorage; + asTop: () => void; + asIframe: () => void; +}) => T): T { + const storage = new MemoryStorage(); + const top = { + sessionStorage: storage, + location: { hash: "", search: "" }, + } as FakeWindow; + top.self = top; + top.top = top; + const iframe = { + sessionStorage: storage, + location: { hash: "#/login/guest?embed=1&theme=blue&hideJump=0", search: "" }, + } as FakeWindow; + iframe.self = iframe; + iframe.top = top; + + const asTop = () => { + (globalThis as { window: FakeWindow }).window = top; + }; + const asIframe = () => { + (globalThis as { window: FakeWindow }).window = iframe; + }; + + asTop(); + try { + return run({ storage, asTop, asIframe }); + } finally { + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window: unknown }).window = originalWindow; + } + } +} + +test("nested embed=1 does not hide chrome on the same-origin top window", () => { + withSharedTab(({ storage, asTop, asIframe }) => { + asTop(); + syncEmbedSessionFromSearch(""); + assert.equal(isEmbedActive(), false); + assert.equal(isChromeHidden(), false); + + asIframe(); + syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0"); + assert.equal(isEmbedActive(), true); + assert.equal(isChromeHidden(), true); + assert.ok(storage.getItem("embed.session.nested")); + assert.equal(storage.getItem("embed.session"), null); + + asTop(); + assert.equal(isEmbedActive(), false); + assert.equal(isChromeHidden(), false); + }); +}); + +test("nested iframe reclaims a polluted shared embed.session key", () => { + withSharedTab(({ storage, asTop, asIframe }) => { + asTop(); + storage.setItem( + "embed.session", + JSON.stringify({ + embed: true, + theme: "light", + hideJump: false, + hideHeader: false, + hideChrome: false, + isolate: false, + }), + ); + assert.equal(isEmbedActive(), true); + + asIframe(); + syncEmbedSessionFromSearch("?embed=1"); + + asTop(); + assert.equal(isEmbedActive(), false); + assert.equal(isChromeHidden(), false); + assert.equal(storage.getItem("embed.session"), null); + }); +}); + +test("nested iframe keeps the top window's own embed session", () => { + withSharedTab(({ asTop, asIframe }) => { + asTop(); + syncEmbedSessionFromSearch("?embed=1&theme=dark"); + syncEmbedSessionFromSearch(""); + assert.equal(isEmbedActive(), true); + + asIframe(); + syncEmbedSessionFromSearch("?embed=1"); + + asTop(); + assert.equal(isEmbedActive(), true); + assert.equal(readEmbedSession()?.theme, "dark"); + }); +}); + +test("nested reclaim strips embed but keeps top isolate", () => { + withSharedTab(({ storage, asTop, asIframe }) => { + asTop(); + storage.setItem( + "embed.session", + JSON.stringify({ + embed: true, + theme: "light", + hideJump: false, + hideHeader: false, + hideChrome: false, + isolate: true, + }), + ); + + asIframe(); + syncEmbedSessionFromSearch("?embed=1"); + + asTop(); + assert.equal(isEmbedActive(), false); + assert.equal(isIsolatedSession(), true); + }); +}); diff --git a/frontend-web/src/core/embed/embed-session.ts b/frontend-web/src/core/embed/embed-session.ts index 7482534c..54b8aeaa 100644 --- a/frontend-web/src/core/embed/embed-session.ts +++ b/frontend-web/src/core/embed/embed-session.ts @@ -27,11 +27,133 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs"; * - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等 * localStorage 偏好。 * - * iframe 的浏览器上下文就是它自己的标签页,sessionStorage 天然「一个 iframe 一份、随关闭消失」; - * 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。 + * 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」 + * 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署) + * **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层 + * 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉, + * 路由看起来却没变。因此顶层窗口写 `embed.session`,嵌套 iframe 写 `embed.session.nested`。 */ const EMBED_SESSION_KEY = "embed.session"; +/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */ +const EMBED_SESSION_NESTED_KEY = "embed.session.nested"; +/** + * 顶层窗口认领「本页自己的去 chrome 会话」的标记。嵌套同源 iframe 看到此标记就不会 + * 清掉顶层合法的 embed/hideChrome 持久化。 + */ +const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed"; + +type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean }; + +function getWindow(): TopChromeOwnerWindow | undefined { + if (typeof globalThis === "undefined") return undefined; + return (globalThis as { window?: TopChromeOwnerWindow }).window; +} + +function getSessionStorage(): Storage | null { + try { + return getWindow()?.sessionStorage ?? null; + } catch { + return null; + } +} + +/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */ +function isTopLevelWindow(): boolean { + const w = getWindow(); + if (!w) return true; + try { + return w.self === w.top; + } catch { + return false; + } +} + +function embedSessionKey(): string { + return isTopLevelWindow() ? EMBED_SESSION_KEY : EMBED_SESSION_NESTED_KEY; +} + +function parseEmbedSession(raw: string | null): EmbedSession | null { + if (!raw) return null; + try { + const p = JSON.parse(raw) as Partial; + return { + embed: p.embed === true, + theme: p.theme === "dark" ? "dark" : "light", + hideJump: p.hideJump === true, + hideHeader: p.hideHeader === true, + hideChrome: p.hideChrome === true, + isolate: p.isolate === true, + }; + } catch { + return null; + } +} + +function sessionHidesChrome(session: EmbedSession | null | undefined): boolean { + return ( + session?.embed === true || + session?.hideChrome === true || + session?.hideHeader === true || + session?.hideJump === true + ); +} + +/** 顶层窗口同步过自己的去 chrome 参数后打标,供同源嵌套 iframe 识别「这不是我写脏的」。 */ +function markTopChromeOwner(session: EmbedSession): void { + const w = getWindow(); + if (!w || !isTopLevelWindow()) return; + if (!sessionHidesChrome(session)) return; + w[TOP_CHROME_OWNER_FLAG] = true; +} + +/** + * 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份 + * 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。 + */ +function migrateLegacySharedSessionIfNested(): void { + if (isTopLevelWindow()) return; + try { + const store = getSessionStorage(); + if (!store || store.getItem(EMBED_SESSION_NESTED_KEY)) return; + const legacy = store.getItem(EMBED_SESSION_KEY); + if (legacy) store.setItem(EMBED_SESSION_NESTED_KEY, legacy); + } catch { + /* noop */ + } +} + +function reclaimPollutedTopChrome(): void { + if (isTopLevelWindow()) return; + try { + const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined; + if (!top || top[TOP_CHROME_OWNER_FLAG] === true) return; + const store = getSessionStorage(); + if (!store) return; + const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY)); + if (!cur) return; + const next: EmbedSession = { + ...cur, + embed: false, + hideJump: false, + hideHeader: false, + hideChrome: false, + }; + if ( + !next.embed && + !next.hideJump && + !next.hideHeader && + !next.hideChrome && + !next.isolate + ) { + store.removeItem(EMBED_SESSION_KEY); + return; + } + store.setItem(EMBED_SESSION_KEY, JSON.stringify(next)); + } catch { + /* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */ + } +} export type EmbedTheme = "light" | "dark"; @@ -101,20 +223,10 @@ export function readEmbedThemeParam(search: string): string { return (toSearchParams(search).get("theme") ?? "").trim(); } -/** 读本标签页的嵌入会话;无 → null。 */ +/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */ export function readEmbedSession(): EmbedSession | null { try { - const raw = sessionStorage.getItem(EMBED_SESSION_KEY); - if (!raw) return null; - const p = JSON.parse(raw) as Partial; - return { - embed: p.embed === true, - theme: p.theme === "dark" ? "dark" : "light", - hideJump: p.hideJump === true, - hideHeader: p.hideHeader === true, - hideChrome: p.hideChrome === true, - isolate: p.isolate === true, - }; + return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null); } catch { return null; } @@ -123,11 +235,14 @@ export function readEmbedSession(): EmbedSession | null { /** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */ function writeEmbedSession(next: EmbedSession): void { try { + const store = getSessionStorage(); + if (!store) return; + const key = embedSessionKey(); if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) { - sessionStorage.removeItem(EMBED_SESSION_KEY); + store.removeItem(key); return; } - sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next)); + store.setItem(key, JSON.stringify(next)); } catch { /* noop */ } @@ -182,6 +297,7 @@ export function isIsolatedSession(): boolean { * 之前置位的开关不会被抹掉。 */ export function syncEmbedSessionFromSearch(search: string): void { + migrateLegacySharedSessionIfNested(); const cur = readEmbedSession() ?? { @@ -219,6 +335,8 @@ export function syncEmbedSessionFromSearch(search: string): void { else if (isolateParam === "off") next.isolate = false; writeEmbedSession(next); + if (isTopLevelWindow()) markTopChromeOwner(next); + else reclaimPollutedTopChrome(); } /** @@ -227,8 +345,9 @@ export function syncEmbedSessionFromSearch(search: string): void { * 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。 */ function currentHashSearch(): string { - if (typeof window === "undefined") return ""; - const hash = window.location.hash || ""; + const w = getWindow(); + if (!w) return ""; + const hash = w.location?.hash || ""; const i = hash.indexOf("?"); return i >= 0 ? hash.slice(i) : ""; }