Merge branch 'master-nj' into 'master'

feat: 舆情分析智能体改为经 Gateway 转发外部 AG-UI 网关

See merge request knowledge-base-bj/deerflow-code!32
This commit is contained in:
董宏凯 committed 2026-08-26 09:00:55 +00:00
commit f1245da3e4
46 files changed
+2825 -363

No files matched your search

+3 -1
View File
@@ -82,7 +82,9 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (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-<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`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `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 等侧栏宽度过渡稳定后再测量高亮框。
- **智能体使用引导 (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>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
+1 -1
View File
@@ -88,7 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **技能地址管理 (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, openMode, loginParams, appendAuth, authTokenParam, authNameParam, 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 (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`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. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text). A premature SSE close (SDK treats it as success and restores the send button while the backend run is still occupying the thread) is recovered by `confirmOrRejoin`: hold the stop button, `joinStream` the live run, and on 409 auto-reconnect instead of a fake AI bubble. `sse_consumer` no longer ends a resumable (`on_disconnect=continue`) stream on a false-positive `is_disconnected()`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+2 -2
View File
@@ -156,8 +156,8 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_SENTIMENT_AGENT_VISIBLE: "true",
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
// 留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/alg/stream/report-agent",
// 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent",
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
@@ -835,7 +835,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
);
const cardLayout = (
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<Card
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 pb-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
@@ -893,7 +896,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
);
const listLayout = (
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div
className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"
data-agent-guide="agent-card"
>
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -21,6 +21,11 @@ import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/s
import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card";
import {
AgentGuide,
isAgentGuideEnvironment,
startAgentGuide,
} from "./agent-guide";
const PAGE_SIZE = 12;
@@ -398,11 +403,23 @@ export function AgentGallery() {
{t.agents.description}
</p>
</div>
<Button onClick={handleNewAgent}>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title="重新播放智能体使用引导"
onClick={() => startAgentGuide("list")}
>
<Compass className="mr-1.5 h-4 w-4" />
使用引导
</Button>
<Button onClick={handleNewAgent} data-agent-guide="new-agent-btn">
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
</div>
</div>
{/* Tabs */}
<Tabs
@@ -421,7 +438,10 @@ export function AgentGallery() {
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsList
className="w-auto max-w-full flex-wrap justify-start self-start"
data-agent-guide="tabs"
>
<TabsTrigger value="mine">我的</TabsTrigger>
{defaultSquare && (
<TabsTrigger
@@ -475,6 +495,7 @@ export function AgentGallery() {
</div>
</div>
{showSearchBar && (
<div data-agent-guide="search">
<TagSearchBar
// Remount per tab so the internal keyword input / 展开 state also
// resets when the parent-owned filters are cleared on tab switch.
@@ -506,6 +527,7 @@ export function AgentGallery() {
</Select>
}
/>
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
@@ -593,6 +615,7 @@ export function AgentGallery() {
</TabsContent>
)}
</Tabs>
<AgentGuide stage="list" eligible={isAgentGuideEnvironment()} />
</div>
);
}
@@ -0,0 +1,332 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Guide, type GuideStep } from "tdesign-react";
import { env } from "@/env";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie";
/**
* 智能体使用引导(三段式,基于 TDesign React 的 Guide 组件),完整覆盖
* 「浏览管理 → 创建与配置 → 发起问答」流程:
*
* - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、
* 创建入口;最后一步「去创建智能体 →」直接跳到创建页衔接下一段。
* - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、
* 知识空间、SOUL.md、技能、保存。
* - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与
* 历史、常用问题、输入框、右侧详情面板。
*
* 记忆语义与简洁模式引导(compact-mode-guide.tsx)一致:每个阶段各自记一个
* localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开
* 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。
*
* 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤
* (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的
* 目标元素会抛错,不能盲传。
*/
export type AgentGuideStage = "list" | "create" | "chat";
const finishedKey = (stage: AgentGuideStage) =>
`deerflow.agent-guide.${stage}.finished`;
const startEventName = (stage: AgentGuideStage) =>
`agent-guide:start:${stage}`;
/** 手动触发某阶段的引导(各页面头部「使用引导」按钮调用,忽略已看过记忆)。 */
export function startAgentGuide(stage: AgentGuideStage) {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(startEventName(stage)));
}
}
/** 环境闸门:iframe 嵌入 / 去 chrome / 强制收起侧栏(无界)等环境不弹引导。 */
export function isAgentGuideEnvironment(): boolean {
return (
!env.FORCE_COLLAPSED_SIDEBAR &&
!isEmbedActive() &&
!isHideChromeActive() &&
!isWujieEnv()
);
}
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
const TARGET = {
// list 阶段(智能体列表页)
tabs: '[data-agent-guide="tabs"]',
search: '[data-agent-guide="search"]',
agentCard: '[data-agent-guide="agent-card"]',
newAgentBtn: '[data-agent-guide="new-agent-btn"]',
// create 阶段(创建页)
createChatInput: '[data-agent-guide="create-chat-input"]',
createBasic: '[data-agent-guide="create-basic"]',
createModel: '[data-agent-guide="create-model"]',
createKnowledge: '[data-agent-guide="create-knowledge"]',
createSoul: '[data-agent-guide="create-soul"]',
createSkills: '[data-agent-guide="create-skills"]',
createSaveBtn: '[data-agent-guide="create-save-btn"]',
// chat 阶段(智能体对话页)
chatSidebar: '[data-agent-guide="chat-sidebar"]',
chatQuestions: '[data-agent-guide="chat-questions"]',
chatInput: '[data-agent-guide="chat-input"]',
chatDetail: '[data-agent-guide="chat-detail"]',
} as const;
// 等布局稳定后再启动:Guide 只在步骤切换时测量目标位置,起步太早会把高亮框
// 定位到过渡中的坐标且不会自行纠正。
const START_DELAY_MS = 800;
interface StepSpec {
/** 高亮目标选择器;缺省(dialog 步骤)以 body 兜底。 */
selector?: string;
mode: "popup" | "dialog";
placement: NonNullable<GuideStep["placement"]>;
title: string;
body: string;
}
const LIST_STEPS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "欢迎使用智能体",
body: "这里是你的智能体中心。接下来花几分钟走完整个流程:浏览与管理智能体 → 创建与配置 → 发起问答。",
},
{
selector: TARGET.tabs,
mode: "popup",
placement: "bottom",
title: "分区浏览",
body: "「我的」放着你创建和收藏的智能体;各「广场」是他人发布共享的,可直接使用或收藏;「私密广场」需要密码访问(管理员还可以查看「内置智能体」)。",
},
{
selector: TARGET.search,
mode: "popup",
placement: "bottom",
title: "搜索与排序",
body: "支持关键词、标签搜索,可按最新创建、最近更新、名称排序;右侧按钮可在卡片 / 列表两种视图间切换。",
},
{
selector: TARGET.agentCard,
mode: "popup",
placement: "top",
title: "智能体卡片",
body: "卡片展示名称、简介、模型与技能徽章;底部操作栏支持收藏、置顶、打标签、发布到广场、复制、编辑、删除;点「对话」直接进入该智能体的问答。",
},
{
selector: TARGET.newAgentBtn,
mode: "popup",
placement: "bottom-left",
title: "创建智能体",
body: "准备好了就点这里,开始创建你的专属智能体。点「去创建智能体」直接进入下一站。",
},
];
const CREATE_STEPS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "创建智能体",
body: "页面分两栏:左侧直接与模型对话,让它帮你配置;右侧表单手动填写。两边内容实时联动,选顺手的方式即可。",
},
{
selector: TARGET.createChatInput,
mode: "popup",
placement: "top",
title: "对话式创建",
body: "在这里描述你想要的智能体,例如「帮我做一个周报助手,语气正式、要点条目化」。模型会追问细节并给出配置方案,你确认后方案自动填入右侧表单,可继续微调。",
},
{
selector: TARGET.createBasic,
mode: "popup",
placement: "left",
title: "基本信息",
body: "「名称」显示在卡片和对话页;「描述」用一句话说明它擅长什么,方便日后挑选。",
},
{
selector: TARGET.createModel,
mode: "popup",
placement: "left",
title: "运行模型与入库",
body: "「模型」选择该智能体被使用时调用的模型;「入库模板名」配置后,对话生成的文档可一键发布入库(不需要可留空)。",
},
{
selector: TARGET.createKnowledge,
mode: "popup",
placement: "left",
title: "知识空间",
body: "为智能体挂载知识库,回答时会优先检索其中的资料,让结果更贴合你的业务数据。",
},
{
selector: TARGET.createSoul,
mode: "popup",
placement: "left",
title: "SOUL.md · 人设",
body: "智能体的「灵魂文件」——写清角色定位、能力边界、工作步骤和输出格式。这份提示词直接决定回答质量,是最值得花时间打磨的配置。",
},
{
selector: TARGET.createSkills,
mode: "popup",
placement: "left",
title: "技能挂载",
body: "从可用技能中勾选要赋予的能力,已选技能会作为工具在对话中被调用;全部不选则是纯对话型智能体。",
},
{
selector: TARGET.createSaveBtn,
mode: "popup",
placement: "top",
title: "创建并保存",
body: "配置完成后点这里保存(左侧对话只是试用场,内容不会自动保存)。保存成功会回到智能体列表。",
},
{
mode: "dialog",
placement: "center",
title: "开始问答",
body: "回到列表页后,点智能体卡片上的「对话」按钮即可开始问答;进入对话页还会有最后一段引导。",
},
];
const CHAT_STEPS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "智能体问答",
body: "一切就绪!这里就是与智能体对话的工作区,快速认识一下。",
},
{
selector: TARGET.chatSidebar,
mode: "popup",
placement: "right",
title: "切换与历史",
body: "左栏顶部可在「我的 / 广场」智能体之间切换;下方是当前智能体的历史会话,点击任意一条继续上次的对话。",
},
{
selector: TARGET.chatQuestions,
mode: "popup",
placement: "bottom",
title: "常用问题",
body: "智能体预设的开场问题,点击即可直接提问,快速体验它的能力。",
},
{
selector: TARGET.chatInput,
mode: "popup",
placement: "top",
title: "输入框",
body: "输入问题后发送即可开始问答;新对话里还可以挂载知识空间,让回答优先检索知识库。",
},
{
selector: TARGET.chatDetail,
mode: "popup",
placement: "left",
title: "智能体详情",
body: "右侧面板展示当前智能体的配置(人设、技能、常用问题),可以直接修改并即时生效;里面的快捷提问也能一键发送。",
},
];
const STAGE_STEPS: Record<AgentGuideStage, StepSpec[]> = {
list: LIST_STEPS,
create: CREATE_STEPS,
chat: CHAT_STEPS,
};
function isBrowser(): boolean {
return typeof window !== "undefined";
}
function isStageFinished(stage: AgentGuideStage): boolean {
return (
isBrowser() && localStorage.getItem(finishedKey(stage)) === "1"
);
}
function markStageFinished(stage: AgentGuideStage) {
if (isBrowser()) localStorage.setItem(finishedKey(stage), "1");
}
function buildSteps(stage: AgentGuideStage): GuideStep[] {
if (!isBrowser()) return [];
return STAGE_STEPS[stage]
.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,
}));
}
/**
* 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为false 时完全
* 静默(不监听、不自动弹)。挂在三个页面各自的作用域内。
*/
export function AgentGuide({
stage,
eligible,
}: {
stage: AgentGuideStage;
eligible: boolean;
}) {
const router = useRouter();
const [current, setCurrent] = useState(-1);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
const steps = buildSteps(stage);
if (steps.length === 0) return;
stepsRef.current = steps;
setCurrent(0);
}, [stage]);
// 自动演示:没显式结束过(没点过完成/跳过)时,每次进入该页都演示;是否
// 「已看过」只由 finish 回调(完成/跳过)写入。
const autoTriedRef = useRef(false);
useEffect(() => {
if (!eligible || autoTriedRef.current) return;
autoTriedRef.current = true;
if (isStageFinished(stage)) return;
const timer = setTimeout(start, START_DELAY_MS);
return () => {
clearTimeout(timer);
// 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。
autoTriedRef.current = false;
};
}, [eligible, stage, start]);
// 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。
useEffect(() => {
if (!eligible) return;
const handler = () => start();
window.addEventListener(startEventName(stage), handler);
return () =>
window.removeEventListener(startEventName(stage), handler);
}, [eligible, stage, start]);
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」。
// list 阶段的最后一步按钮文案是「去创建智能体 →」,完成即跳转衔接下一段。
const finish = useCallback(() => {
markStageFinished(stage);
setCurrent(-1);
if (stage === "list") {
router.push("/page/workspace/agents/new");
}
}, [stage, router]);
if (!eligible) return null;
return (
<Guide
current={current}
steps={stepsRef.current}
onChange={setCurrent}
onFinish={finish}
onSkip={finish}
finishButtonProps={
stage === "list" ? { content: "去创建智能体 →" } : undefined
}
/>
);
}
@@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
/**
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
*/
export function SentimentAgentCard({
@@ -152,6 +152,7 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
return (
<div
className="relative"
data-compact-guide="theme-switcher"
onMouseEnter={() => {
cancelClose();
setOpen(true);
@@ -251,6 +252,7 @@ export function LayoutModeSwitcher({
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
title="界面模式"
aria-label="界面模式"
data-compact-guide="layout-mode"
>
<TriggerIcon className="size-4" />
</Button>
@@ -76,7 +76,12 @@ import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { isWriteFileInFlight } from "@/core/artifacts/write-file-in-flight";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import {
isSandboxStreamPerfEnabled,
useSandboxFollowScroll,
} from "@/core/artifacts/sandbox-stream-perf";
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
@@ -304,19 +309,33 @@ export function ArtifactFileDetail({
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
// 仅当「当前这个文件」的 write_file 还没返回 tool result 时才算在写。
// 不能用 thread.isLoading:用户协助后的下一轮问答也会 loading,否则会把已写完的
// md 从 CodeMirror 拽回巨大 textarea,主线程卡住、聊天变成「发送中然后一口气出来」。
const writeFileInFlight = useMemo(() => {
if (!isWriteFileVirtual || isImageFile(filepath)) {
return false;
}
return isWriteFileInFlight(thread, filepathFromProps);
}, [filepath, filepathFromProps, isWriteFileVirtual, thread.messages]);
// 圆桌/主聊天沙箱:write_file 虚拟预览的 md 在该文件写完后切回真实 artifacts API
// (可加载 + PATCH 保存)。后续问答轮次的 loading 不再把预览拽回去。
const editVirtualMarkdown = useMemo(() => {
return (
allowVirtualMarkdownEdit &&
isWriteFileVirtual &&
!isImageFile(filepath) &&
!isMock &&
!thread.isLoading &&
!writeFileInFlight &&
checkCodeFile(filepath).language === "markdown"
);
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
}, [
allowVirtualMarkdownEdit,
isWriteFileVirtual,
filepath,
isMock,
writeFileInFlight,
]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
@@ -373,7 +392,7 @@ export function ArtifactFileDetail({
language === "markdown" &&
!isSkillFile &&
!isMock &&
!thread.isLoading &&
!writeFileInFlight &&
(canEditArtifact || useWriteFileStreaming);
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
@@ -401,6 +420,14 @@ export function ArtifactFileDetail({
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
@@ -414,7 +441,7 @@ export function ArtifactFileDetail({
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive);
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
@@ -877,7 +904,7 @@ export function ArtifactFileDetail({
}
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
return;
}
@@ -887,14 +914,20 @@ export function ArtifactFileDetail({
}
setViewMode("code");
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
}, [
documentRewriteActive,
isPreviewStreaming,
isSupportPreview,
language,
writeFileInFlight,
]);
useEffect(() => {
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
return;
}
if (thread.isLoading) {
if (writeFileInFlight || documentRewriteActive) {
setViewMode("code");
return;
}
@@ -906,7 +939,13 @@ export function ArtifactFileDetail({
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
isSupportPreview,
writeFileInFlight,
]);
const scrollArtifactToBottom = useCallback(
(behavior: ScrollBehavior = "auto") => {
@@ -924,39 +963,12 @@ export function ArtifactFileDetail({
[],
);
useEffect(() => {
if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) {
return;
}
const tick = () => {
scrollArtifactToBottom("auto");
};
tick();
const intervalId = window.setInterval(tick, 180);
return () => window.clearInterval(intervalId);
}, [
autoScrollEnabled,
documentRewriteActive,
scrollArtifactToBottom,
thread.isLoading,
]);
useEffect(() => {
if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) {
return;
}
scrollArtifactToBottom("auto");
}, [
autoScrollEnabled,
displayContent,
documentRewriteActive,
scrollArtifactToBottom,
thread.isLoading,
useSandboxFollowScroll({
follow: isLiveStreaming && autoScrollEnabled,
scrollToBottom: scrollArtifactToBottom,
contentKey: sandboxDisplayText,
viewMode,
]);
});
useEffect(() => {
const handleResumeEvent = () => {
@@ -1030,13 +1042,13 @@ export function ArtifactFileDetail({
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) {
if (!isLiveStreaming || !autoScrollEnabled) {
return;
}
setAutoScrollEnabled(false);
setShowResumeAutoScroll(true);
}, [autoScrollEnabled, thread.isLoading]);
}, [autoScrollEnabled, isLiveStreaming]);
const handleResumeAutoScroll = useCallback(() => {
setAutoScrollEnabled(true);
@@ -1743,7 +1755,7 @@ export function ArtifactFileDetail({
language === "html" ||
language === "mermaid") && (
<ArtifactFilePreview
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
content={sandboxDisplayText}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
@@ -1780,7 +1792,7 @@ export function ArtifactFileDetail({
value={
canEditArtifact || disableDesensitize
? (displayContent ?? "")
: desensitize(displayContent ?? "")
: sandboxDisplayText
}
readonly={!canEditArtifact || documentRewriteActive}
onChange={
@@ -1791,6 +1803,7 @@ export function ArtifactFileDetail({
}
: undefined
}
isStreaming={writeFileInFlight || documentRewriteActive}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
@@ -11,12 +11,11 @@ import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useThread } from "./messages/context";
const customDarkTheme = monokaiInit({
settings: {
background: "transparent",
@@ -34,7 +33,7 @@ const customLightTheme = basicLightInit({
},
});
export function CodeEditor({
export const CodeEditor = memo(function CodeEditor({
className,
placeholder,
value,
@@ -43,6 +42,7 @@ export function CodeEditor({
disabled,
autoFocus,
settings,
isStreaming = false,
streamingReadonlyFallback = true,
}: {
className?: string;
@@ -53,11 +53,10 @@ export function CodeEditor({
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
/** Parent-owned streaming flag so this editor does not subscribe to `thread`. */
isStreaming?: boolean;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
} = useThread();
const { resolvedTheme } = useTheme();
const extensions = useMemo(() => {
@@ -81,7 +80,7 @@ export function CodeEditor({
className,
)}
>
{isLoading && streamingReadonlyFallback ? (
{isStreaming && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
@@ -116,4 +115,4 @@ export function CodeEditor({
)}
</div>
);
}
});
@@ -0,0 +1,318 @@
"use client";
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)。
*
* 触发方式:
* - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写
* localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。
* - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过
* COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到
* /chats/new,由本组件在挂载后消费。
*
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
*/
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
const COMPACT_GUIDE_PENDING_KEY = "deerflow.compact-guide-pending";
/** 同页手动触发引导的窗口事件名(「设置和更多」→「界面使用引导」)。 */
export const COMPACT_GUIDE_START_EVENT = "compact-guide:start";
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
const TARGET = {
nav: '[data-compact-guide="sidebar-nav"]',
recentChats: '[data-compact-guide="recent-chats"]',
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;
// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置,
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
const START_DELAY_MS = 800;
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
// 跳过该步避免引导卡死。
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export function isCompactGuideDone(): boolean {
return isBrowser() && localStorage.getItem(COMPACT_GUIDE_DONE_KEY) === "1";
}
function markCompactGuideDone() {
if (isBrowser()) localStorage.setItem(COMPACT_GUIDE_DONE_KEY, "1");
}
/** 跨页触发:非问答页点击「界面使用引导」时先记录请求,导航到 /chats/new 后消费。 */
export function requestCompactGuideStart() {
if (isBrowser()) sessionStorage.setItem(COMPACT_GUIDE_PENDING_KEY, "1");
}
function hasCompactGuideRequest(): boolean {
return (
isBrowser() &&
sessionStorage.getItem(COMPACT_GUIDE_PENDING_KEY) === "1"
);
}
function consumeCompactGuideRequest() {
if (isBrowser()) sessionStorage.removeItem(COMPACT_GUIDE_PENDING_KEY);
}
interface StepSpec {
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
selector?: string;
/**
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
*/
dependsOn?: string;
mode: "popup" | "dialog";
placement: NonNullable<GuideStep["placement"]>;
title: string;
body: string;
}
const STEP_SPECS: StepSpec[] = [
{
mode: "dialog",
placement: "center",
title: "欢迎使用简洁模式",
body: "当前已隐藏顶部导航栏与全局侧边栏,为您留出更大的工作区。接下来花一分钟认识一下界面布局。",
},
{
selector: TARGET.nav,
mode: "popup",
placement: "right",
title: "功能导航",
body: "新对话、智能体、技能、AI 写作、深度研究等主要入口都在左侧这里,点击即可切换(菜单项由管理员统一配置)。",
},
{
selector: TARGET.recentChats,
mode: "popup",
placement: "right",
title: "聊天记录",
body: "最近的对话会保留在这里,点击任意一条即可继续上次的问答。",
},
{
selector: TARGET.settingsMenu,
mode: "popup",
placement: "right-top",
title: "设置和更多",
body: "通知、设置、用户排行榜,以及菜单管理、技能管理等管理入口都收纳在这个菜单里。",
},
{
selector: TARGET.themeSwitcher,
mode: "popup",
placement: "bottom-left",
title: "主题换肤",
body: "在这里一键切换红色、蓝色、深蓝等多套界面主题。",
},
{
selector: TARGET.layoutMode,
mode: "popup",
placement: "bottom-left",
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",
placement: "top",
title: "开始提问",
body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。",
},
];
/**
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
* 满足「首次进入」条件时自动演示一遍。
*/
export function CompactModeGuide({
eligible,
autoStart = false,
}: {
eligible: boolean;
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const specsRef = useRef<StepSpec[]>([]);
const stepsRef = useRef<GuideStep[]>([]);
const pollTimerRef = useRef<number | null>(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(() => {
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 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
// 又会被当成已看过。
const autoTriedRef = useRef(false);
useEffect(() => {
if (!eligible || !autoStart || autoTriedRef.current) return;
autoTriedRef.current = true;
if (isCompactGuideDone()) return;
const timer = setTimeout(start, START_DELAY_MS);
return () => {
clearTimeout(timer);
// 组件在计时窗口内卸载(用户迅速离开页面)则下次落地再演示。
autoTriedRef.current = false;
};
}, [eligible, autoStart, start]);
// 跨页手动触发:非问答页点「界面使用引导」→ 记录请求 → 导航到 /chats/new → 此处启动。
// 请求只在定时器真正启动引导时才消费 —— StrictMode 下 effect 会 mount→cleanup→mount
// 双跑,若在检查时就删除,第二次跑就拿不到请求了。
useEffect(() => {
if (!eligible) return;
if (!hasCompactGuideRequest()) return;
const timer = setTimeout(() => {
consumeCompactGuideRequest();
start();
}, START_DELAY_MS);
return () => clearTimeout(timer);
}, [eligible, start]);
// 同页手动触发:已在问答页时「界面使用引导」直接广播事件启动。
useEffect(() => {
if (!eligible) return;
const handler = () => start();
window.addEventListener(COMPACT_GUIDE_START_EVENT, handler);
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;
return (
<Guide
current={current}
steps={stepsRef.current}
onChange={goTo}
onFinish={finish}
onSkip={finish}
/>
);
}
@@ -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<HTMLDivElement>(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 (
<>
<div className={cn("flex min-w-0 items-center gap-1", className)}>
<div
className={cn("flex min-w-0 items-center gap-1", className)}
data-compact-guide="prompt-bar"
>
{prompts.length > 0 && (
<>
{overflowing && (
@@ -148,7 +171,14 @@ export function CustomPromptBar({
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
<ManagePromptsDialog
open={manageOpen}
onOpenChange={(next) => {
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
if (!next && guideOwnsDialogRef.current) return;
setManageOpen(next);
}}
/>
</>
);
}
@@ -257,7 +287,10 @@ function ManagePromptsDialog({
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-xl"
data-compact-guide="prompt-dialog"
>
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
@@ -206,7 +206,10 @@ export function RecentChatList({
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroup
className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"
data-compact-guide="recent-chats"
>
<SidebarGroupLabel
className={cn(
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
@@ -176,7 +176,10 @@ export function WorkspaceNavChatList({
};
return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarGroup
className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}
data-compact-guide="sidebar-nav"
>
<SidebarMenu className="gap-1">
{(showCollapseTrigger || newChatItem) && (
<SidebarMenuItem>
@@ -7,6 +7,7 @@ import {
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
Compass,
CpuIcon,
DatabaseZapIcon,
GaugeIcon,
@@ -22,6 +23,7 @@ import {
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import {
DropdownMenu,
@@ -46,6 +48,10 @@ import {
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import {
COMPACT_GUIDE_START_EVENT,
requestCompactGuideStart,
} from "./compact-mode-guide";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -73,6 +79,7 @@ function NavMenuButtonContent({
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
@@ -90,6 +97,20 @@ export function WorkspaceNavMenu() {
setMounted(true);
}, []);
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
const startCompactGuide = () => {
if (/^\/page\/workspace\/chats\/.+/.test(pathname)) {
window.dispatchEvent(new CustomEvent(COMPACT_GUIDE_START_EVENT));
return;
}
requestCompactGuideStart();
navigate("/page/workspace/chats/new", {
replace: true,
state: { resetAt: Date.now() },
});
};
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
@@ -116,7 +137,7 @@ export function WorkspaceNavMenu() {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
@@ -155,6 +176,10 @@ export function WorkspaceNavMenu() {
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={startCompactGuide}>
<Compass />
界面使用引导
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
+65 -23
View File
@@ -1,32 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
loadArtifactContentFromToolCall,
} from "./loader";
import {
SANDBOX_STREAM_FLUSH_MS,
isSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
import { isArtifactApiPath } from "./streaming-write-preview";
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
function hasToolResult(
thread: ReturnType<typeof useThread>["thread"],
toolCallId?: string,
) {
if (!toolCallId) {
return false;
}
@@ -81,6 +74,48 @@ function applyPendingTextEdit({
return null;
}
/**
* Keep the latest write_file text in a ref (always current for later readers)
* but only publish a display snapshot on a timer while the run is in flight.
* Display therefore does not follow `thread` object identity / every SSE chunk.
*/
function useThrottledWriteFileDisplay({
filepath,
liveContent,
enabled,
streaming,
}: {
filepath: string;
liveContent: string | undefined;
enabled: boolean;
streaming: boolean;
}) {
const latestRef = useRef(liveContent);
latestRef.current = liveContent;
const [displayed, setDisplayed] = useState(liveContent);
useEffect(() => {
if (!enabled) {
setDisplayed(latestRef.current);
return;
}
const flush = () => {
setDisplayed((previous) =>
Object.is(previous, latestRef.current) ? previous : latestRef.current,
);
};
if (!streaming) {
flush();
return;
}
flush();
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
return () => window.clearInterval(intervalId);
}, [enabled, filepath, streaming]);
return enabled && streaming ? displayed : liveContent;
}
export function useArtifactContent({
filepath,
threadId,
@@ -94,12 +129,19 @@ export function useArtifactContent({
return isWriteFileStreamingUrl(filepath);
}, [filepath]);
const { thread, isMock, publicShareCode } = useThread();
const content = useMemo(() => {
if (useWriteFileStreaming) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
}
return null;
}, [filepath, useWriteFileStreaming, thread]);
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const liveWriteFileContent = useWriteFileStreaming
? loadArtifactContentFromToolCall({ url: filepath, thread })
: undefined;
const writeFileInFlight =
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
const displayedWriteFileContent = useThrottledWriteFileDisplay({
filepath,
liveContent: liveWriteFileContent,
enabled: streamPerfEnabled && useWriteFileStreaming,
streaming: writeFileInFlight,
});
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SANDBOX_STREAM_PERF_QUERY,
resolveSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
test("sandbox stream perf defaults to on", () => {
assert.equal(resolveSandboxStreamPerfEnabled({}), true);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: null }), true);
});
test("sandbox stream perf can be rolled back from hash or search query", () => {
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/chats/new?sandboxStreamPerf=0",
}),
false,
);
assert.equal(
resolveSandboxStreamPerfEnabled({
search: `?${SANDBOX_STREAM_PERF_QUERY}=off`,
}),
false,
);
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/agents/abc/chats/def?foo=1&sandboxStreamPerf=false",
}),
false,
);
});
test("sandbox stream perf query wins over localStorage", () => {
assert.equal(
resolveSandboxStreamPerfEnabled({
hash: "#/page/workspace/chats/new?sandboxStreamPerf=1",
storageValue: "0",
}),
true,
);
});
test("sandbox stream perf can be rolled back from localStorage", () => {
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "0" }), false);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "off" }), false);
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "1" }), true);
});
@@ -0,0 +1,115 @@
import { useEffect } from "react";
/**
* Sandbox streaming performance guard (write_file live preview).
*
* Default ON. Rollback without a rebuild:
* 1. Append `?sandboxStreamPerf=0` to the page URL and refresh
* e.g. `/#/page/workspace/chats/new?sandboxStreamPerf=0`
* 2. Or in the console:
* localStorage.setItem("deerflow.sandbox-stream-perf", "0"); location.reload();
*
* Restore the optimized path:
* localStorage.removeItem("deerflow.sandbox-stream-perf") and drop the query, then refresh.
*/
export const SANDBOX_STREAM_PERF_STORAGE_KEY = "deerflow.sandbox-stream-perf";
export const SANDBOX_STREAM_PERF_QUERY = "sandboxStreamPerf";
/** Aligns with roundtable write_file preview (~12fps). */
export const SANDBOX_STREAM_FLUSH_MS = 80;
export const SANDBOX_LEGACY_SCROLL_INTERVAL_MS = 180;
function parseFlag(raw: string | null | undefined): boolean | null {
if (raw == null) return null;
const value = raw.trim().toLowerCase();
if (value === "0" || value === "off" || value === "false" || value === "no") {
return false;
}
if (value === "1" || value === "on" || value === "true" || value === "yes") {
return true;
}
return null;
}
function queryValue(search: string, key: string): string | null {
if (!search) return null;
const normalized = search.startsWith("?") ? search.slice(1) : search;
return new URLSearchParams(normalized).get(key);
}
export function resolveSandboxStreamPerfEnabled(input: {
search?: string;
hash?: string;
storageValue?: string | null;
}): boolean {
const searchFlag = parseFlag(queryValue(input.search ?? "", SANDBOX_STREAM_PERF_QUERY));
if (searchFlag != null) return searchFlag;
const hash = input.hash ?? "";
const hashQueryIndex = hash.indexOf("?");
if (hashQueryIndex >= 0) {
const hashFlag = parseFlag(
queryValue(hash.slice(hashQueryIndex), SANDBOX_STREAM_PERF_QUERY),
);
if (hashFlag != null) return hashFlag;
}
const storedFlag = parseFlag(input.storageValue);
if (storedFlag != null) return storedFlag;
return true;
}
export function isSandboxStreamPerfEnabled(): boolean {
if (typeof window === "undefined") return true;
let storageValue: string | null = null;
try {
storageValue = window.localStorage.getItem(SANDBOX_STREAM_PERF_STORAGE_KEY);
} catch {
storageValue = null;
}
return resolveSandboxStreamPerfEnabled({
search: window.location.search,
hash: window.location.hash,
storageValue,
});
}
export function useSandboxFollowScroll({
follow,
scrollToBottom,
contentKey,
viewMode,
}: {
follow: boolean;
scrollToBottom: (behavior?: ScrollBehavior) => boolean;
contentKey: string;
viewMode: string;
}) {
const perfEnabled = isSandboxStreamPerfEnabled();
useEffect(() => {
if (!perfEnabled || !follow) return;
let raf = 0;
raf = requestAnimationFrame(() => {
raf = 0;
scrollToBottom("auto");
});
return () => {
if (raf) cancelAnimationFrame(raf);
};
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
useEffect(() => {
if (perfEnabled || !follow) return;
const tick = () => {
scrollToBottom("auto");
};
tick();
const intervalId = window.setInterval(tick, SANDBOX_LEGACY_SCROLL_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [perfEnabled, follow, scrollToBottom]);
useEffect(() => {
if (perfEnabled || !follow) return;
scrollToBottom("auto");
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
"report.md",
);
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
});
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
const inFlight = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
],
};
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
const completed = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
],
};
assert.equal(
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
false,
);
});
test("a later thread turn does not treat a finished write as in-flight", () => {
const laterTurn = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
{ type: "human", content: "按方案 A" },
{
type: "ai",
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
},
],
};
assert.equal(
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
false,
);
});
@@ -0,0 +1,77 @@
import { isImageFile } from "../utils/files";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
export function resolveWriteFilePath(filepath: string): string {
if (filepath.startsWith("write-file:")) {
try {
return decodeURIComponent(new URL(filepath).pathname).trim();
} catch {
const rest = filepath.slice("write-file:".length);
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
}
}
return filepath.trim();
}
type WriteFileThreadLike = {
messages: Array<{
type?: string;
tool_call_id?: string;
tool_calls?: Array<{
id?: string;
name?: string;
args?: unknown;
}>;
}>;
};
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
/** True only while THIS file's write_file call has not yet returned a tool result. */
export function isWriteFileInFlight(
thread: WriteFileThreadLike,
filepath: string,
): boolean {
const path = resolveWriteFilePath(filepath);
if (!path) {
return false;
}
for (const message of thread.messages) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of message.tool_calls) {
if (toolCall.name !== "write_file") {
continue;
}
const args = toolCall.args as { path?: unknown } | undefined;
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
if (callPath !== path) {
continue;
}
if (!hasToolResult(thread, toolCall.id)) {
return true;
}
}
}
return false;
}
@@ -0,0 +1,228 @@
/**
* 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<string, string>();
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<T>(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("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
syncEmbedSessionFromSearch("");
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
}),
);
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("top sync after old nested pollution does not mark owner", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
}),
);
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
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,
}),
);
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);
assert.equal(readEmbedSession()?.topOwned, 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);
});
});
test("top reclaim strips embed but keeps isolate without nested help", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
+251 -39
View File
@@ -27,11 +27,54 @@ 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`;
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
*/
const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
* 嵌套同源 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;
}
export type EmbedTheme = "light" | "dark";
@@ -53,6 +96,154 @@ export interface EmbedSession {
hideChrome: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean;
/**
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
*/
topOwned?: boolean;
}
function parseEmbedSession(raw: string | null): EmbedSession | null {
if (!raw) return null;
try {
const p = JSON.parse(raw) as Partial<EmbedSession>;
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,
topOwned: p.topOwned === true,
};
} catch {
return null;
}
}
function sessionHidesChrome(session: EmbedSession | null | undefined): boolean {
return (
session?.embed === true ||
session?.hideChrome === true ||
session?.hideHeader === true ||
session?.hideJump === true
);
}
function emptySession(): EmbedSession {
return {
embed: false,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
topOwned: false,
};
}
function stripChromeFlags(session: EmbedSession): EmbedSession {
return {
...session,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
function persistTopSession(next: EmbedSession): void {
const store = getSessionStorage();
if (!store) return;
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(EMBED_SESSION_KEY);
return;
}
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
}
function isTopChromeOwned(session?: EmbedSession | null): boolean {
const w = getWindow();
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
return session?.topOwned === true;
}
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow();
if (!w || !isTopLevelWindow()) return;
if (!sessionHidesChrome(session) || !session.topOwned) return;
w[TOP_CHROME_OWNER_FLAG] = true;
}
/**
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
*/
function reclaimUnownedTopChrome(): void {
if (!isTopLevelWindow()) return;
try {
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur || !sessionHidesChrome(cur)) return;
if (isTopChromeOwned(cur)) {
const w = getWindow();
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
return;
}
persistTopSession(stripChromeFlags(cur));
} catch {
/* noop */
}
}
/**
* 旧代码把嵌套 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;
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur) return;
// 顶层已认领(内存标记或 session.topOwned)则不动。
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
persistTopSession(stripChromeFlags(cur));
} catch {
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
}
}
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
@@ -101,20 +292,21 @@ export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
}
/** 读本标签页的嵌入会话;无 → null。 */
function searchEnablesChromeHide(search: string): boolean {
return (
readEmbedParam(search) === "on" ||
readHideChromeParam(search) === "on" ||
readHideHeaderParam(search) === "on" ||
readHideJumpParam(search) === "on"
);
}
/** 读本浏览上下文的嵌入会话;无 → 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<EmbedSession>;
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,
};
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
reclaimUnownedTopChrome();
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
return null;
}
@@ -123,11 +315,29 @@ export function readEmbedSession(): EmbedSession | null {
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
sessionStorage.removeItem(EMBED_SESSION_KEY);
const store = getSessionStorage();
if (!store) return;
const key = embedSessionKey();
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(key);
return;
}
sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(key, JSON.stringify(payload));
} catch {
/* noop */
}
@@ -180,18 +390,15 @@ export function isIsolatedSession(): boolean {
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
* 之前置位的开关不会被抹掉。
*
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
*/
export function syncEmbedSessionFromSearch(search: string): void {
const cur =
readEmbedSession() ??
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
migrateLegacySharedSessionIfNested();
if (isTopLevelWindow()) reclaimUnownedTopChrome();
const cur = readEmbedSession() ?? emptySession();
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -218,7 +425,20 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false;
if (isTopLevelWindow()) {
if (searchEnablesChromeHide(search)) {
next.topOwned = true;
} else if (!isTopChromeOwned(next)) {
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
} else {
next.topOwned = true;
}
}
writeEmbedSession(next);
if (isTopLevelWindow()) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
}
/**
@@ -227,8 +447,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) : "";
}
@@ -259,15 +480,6 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void {
const cur =
readEmbedSession() ??
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
const cur = readEmbedSession() ?? emptySession();
writeEmbedSession({ ...cur, isolate: on });
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import { groupMessages } from "./utils";
function groupTypes(messages: Message[]) {
const types: string[] = [];
groupMessages(messages, (group) => {
types.push(group.type);
return group.type;
});
return types;
}
test("write_file tool after 用户协助 opens a processing group instead of erroring", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一份报告" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "ask_clarification", args: {} }],
} as Message,
{
type: "tool",
id: "t1",
name: "ask_clarification",
content: "请选择范围",
tool_call_id: "c1",
} as Message,
{ type: "human", id: "h2", content: "按方案 A" } as Message,
{
type: "tool",
id: "t2",
name: "write_file",
content: "OK",
tool_call_id: "w1",
} as Message,
{
type: "tool",
id: "t3",
name: "write_file",
content: "OK",
tool_call_id: "w2",
} as Message,
]);
assert.deepEqual(types, [
"human",
"assistant:processing",
"assistant:clarification",
"human",
"assistant:processing",
]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "a.md" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "write_file",
content: "OK",
tool_call_id: "w1",
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
+9 -4
View File
@@ -119,10 +119,15 @@ export function groupMessages<T>(
if (open) {
open.messages.push(message);
} else {
console.error(
"Unexpected tool message outside a processing group",
message,
);
// After 用户协助 the last group is human/clarification, so later
// write_file results have nowhere to attach. Opening a processing
// group keeps them renderable and avoids console.error(full message)
// on every MessageList render (that froze the SSE thread).
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
}
continue;
+40 -11
View File
@@ -1,13 +1,14 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { readSentimentAgentEndpoint } from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 舆情分析智能体走 Gateway 反向代理(POST,SSE 返回):
* 前端把 runtime-config.js 的 apiUrl / Basic Auth 随 AG-UI 体传给
* `/api/sentiment-agent/stream`,由后台转发上游(不校验证书、不拦地址)。
* 请求体业务字段仍为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
@@ -91,6 +92,28 @@ export interface RunSentimentAgentOptions {
handlers: SentimentAgentRunHandlers;
}
/** 把 Gateway / FastAPI 错误体展开成可展示的完整字符串。 */
async function readProxyError(response: Response): Promise<string> {
const text = await response.text();
if (!text.trim()) {
return `舆情分析服务请求失败(HTTP ${response.status})。`;
}
try {
const parsed = JSON.parse(text) as { detail?: unknown };
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
return parsed.detail;
}
if (parsed.detail != null) {
return typeof parsed.detail === "object"
? JSON.stringify(parsed.detail, null, 2)
: String(parsed.detail);
}
} catch {
// 非 JSON 时把原始响应原文抛给调用方。
}
return text;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
@@ -99,16 +122,22 @@ export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
const endpoint = readSentimentAgentEndpoint();
if (!endpoint.apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
const response = await apiFetch(`${getBackendBaseURL()}/api/sentiment-agent/stream`, {
method: "POST",
headers: buildSentimentAgentHeaders(),
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
signal,
body: JSON.stringify({
apiUrl: endpoint.apiUrl,
authUsername: endpoint.authUsername,
authPassword: endpoint.authPassword,
threadId,
runId,
state: {},
@@ -119,7 +148,7 @@ export async function runSentimentAgent(
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
throw new Error(await readProxyError(response));
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
@@ -1,9 +1,9 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 舆情分析智能体(经 Gateway 转发的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
* dist/runtime-config.js 切换环境,无需重新打包。前端把这三项随请求传给后台转发上游 SSE。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
@@ -35,25 +35,3 @@ export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 不创建后端 thread、不进 LangGraph —— 问答经 Gateway 转发外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
+430 -172
View File
@@ -24,6 +24,12 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
import {
isAbortLikeError,
isTransientStreamTransportError,
@@ -178,6 +184,61 @@ function getRunMetadataStorage(): {
};
}
/**
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
* but **deletes that key on stream onSuccess**. A premature SSE close
* (proxy timeout / false-positive disconnect) looks like success, so the
* reconnect key vanishes while the backend run is still occupying the
* thread. We keep a second key the SDK never touches and only clear it
* after ``runs.get`` confirms a terminal status.
*/
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
function persistActiveRunId(threadId: string, runId: string): void {
if (typeof window === "undefined") return;
const normalized = normalizeStoredRunId(runId);
if (!normalized) return;
try {
window.sessionStorage.setItem(`lg:stream:${threadId}`, normalized);
window.sessionStorage.setItem(
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
normalized,
);
} catch {
/* private-mode / quota */
}
}
function readActiveRunId(threadId: string): string | null {
if (typeof window === "undefined") return null;
try {
return (
normalizeStoredRunId(
window.sessionStorage.getItem(`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`),
) ||
normalizeStoredRunId(window.sessionStorage.getItem(`lg:stream:${threadId}`))
);
} catch {
return null;
}
}
function clearActiveRunId(threadId: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
window.sessionStorage.removeItem(
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
);
} catch {
/* noop */
}
}
function isInflightRunStatus(status: string | undefined): boolean {
return status === "pending" || status === "running";
}
/**
* 显式停止某会话「服务端正在跑」的 run。
*
@@ -192,9 +253,10 @@ export async function stopThreadRun(
if (!threadId || typeof window === "undefined") return;
let runId: string | null = null;
try {
runId = normalizeStoredRunId(
runId =
normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
) || readActiveRunId(threadId);
} catch {
runId = null;
}
@@ -204,11 +266,7 @@ export async function stopThreadRun(
} catch {
/* 已结束 / 不存在 / 网络错误等忽略 */
}
try {
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
} catch {
/* noop */
}
clearActiveRunId(threadId);
}
/**
@@ -222,13 +280,27 @@ export async function stopActiveThreadBeforeClarificationReply(
let storedRunId: string | null = null;
if (threadId && typeof window !== "undefined") {
try {
storedRunId = normalizeStoredRunId(
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 {
await stopThreadRun(threadId);
@@ -511,6 +583,32 @@ export function useThreadStream({
const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0);
const streamReconnectFailureToastShownRef = useRef(false);
const activeRunIdRef = useRef<string | null>(
threadId && typeof window !== "undefined" ? readActiveRunId(threadId) : null,
);
const confirmOrRejoinRef = useRef<
(reason: "finish" | "error" | "poll" | "conflict") => Promise<
"terminal" | "rejoined" | "unknown"
>
>(async () => "unknown");
// Keep the send button in "stop" until we confirm the backend run is
// terminal. A premature SSE close must not look like the turn ended.
const [streamHoldOpen, setStreamHoldOpen] = useState(
() =>
Boolean(
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
),
);
const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0);
// Filled after useStream; onFinish/onError may read it asynchronously.
const threadStreamRef = useRef<{
isLoading: boolean;
stop: () => void | Promise<void>;
joinStream: (runId: string) => Promise<unknown>;
messages: Message[];
values?: AgentThreadState;
} | null>(null);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
@@ -564,9 +662,6 @@ export function useThreadStream({
const runMetadataStorageRef = useRef<
ReturnType<typeof getRunMetadataStorage> | undefined
>(undefined);
const joinStreamRef = useRef<(runId: string) => Promise<unknown>>(
async () => undefined,
);
if (
typeof window !== "undefined" &&
@@ -586,6 +681,11 @@ export function useThreadStream({
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
if (meta.run_id) {
activeRunIdRef.current = meta.run_id;
persistActiveRunId(meta.thread_id, meta.run_id);
setStreamHoldOpen(true);
}
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
@@ -731,22 +831,10 @@ export function useThreadStream({
// A dropped SSE while the backend run is still alive: rejoin instead of
// toasting "Failed to fetch" / 网络波动 and leaving the answer half-done.
if (isTransientStreamTransportError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
const runId = staleThreadId
? runMetadataStorageRef.current?.getItem(`lg:stream:${staleThreadId}`)
: null;
if (
runId &&
streamReconnectAttemptsRef.current < STREAM_RECONNECT_MAX_ATTEMPTS
) {
streamReconnectAttemptsRef.current += 1;
lastStreamActivityAtRef.current = Date.now();
void joinStreamRef.current(runId).catch(() => {
// Reconciliation will retry while the run stays non-terminal.
});
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error");
return;
}
}
setOptimisticMessages([]);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
@@ -754,11 +842,11 @@ export function useThreadStream({
// silently — there is no live run to rejoin.
if (isRunNotFoundError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (staleThreadId && runMetadataStorageRef.current) {
runMetadataStorageRef.current.removeItem(
`lg:stream:${staleThreadId}`,
);
if (staleThreadId) {
clearActiveRunId(staleThreadId);
}
activeRunIdRef.current = null;
setStreamHoldOpen(false);
return;
}
// A "thread not found" 404 means this route cannot accept another turn.
@@ -787,15 +875,46 @@ export function useThreadStream({
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
const friendly = friendlySendLimitMessage(message);
if (friendly) {
if (isActiveRunConflictError(message)) {
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("conflict").then((result) => {
toast.info(
result === "rejoined"
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
: friendly,
);
});
return;
}
toast.info(friendly);
return;
}
toast.error(message);
},
onFinish(state) {
markStreamActivity();
lastStreamActivityAtRef.current = Date.now();
// Do not trust a clean SSE close as "the run is done". streamResumable
// chats keep the backend run alive on disconnect; the SDK still fires
// onFinish and would otherwise restore the send button.
//
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would
// steal the live SSE and dump tokens only at the end.
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("finish").then((result) => {
if (result === "rejoined") {
return;
}
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(false);
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
});
},
});
@@ -805,7 +924,6 @@ export function useThreadStream({
const [threadValueOverrides, setThreadValueOverrides] = useState<
Partial<AgentThreadState>
>({});
const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
// message id shows up, so we can bind the timestamp to the real id (see the
// reconciliation effect below). ``known`` is the set of human ids that
@@ -813,15 +931,192 @@ export function useThreadStream({
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
null,
);
// Human ids known at optimistic-send time; used to clear optimistic only when
// a *new* server human appears (not when length bumps from tool/checkpoint).
const optimisticKnownHumanIdsRef = useRef<Set<string>>(new Set());
// Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length);
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
joinStreamRef.current = (runId: string) => thread.joinStream(runId);
const confirmOrRejoin = useCallback(
async (
reason: "finish" | "error" | "poll" | "conflict",
): Promise<"terminal" | "rejoined" | "unknown"> => {
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
if (!tid) {
setStreamHoldOpen(false);
return "unknown";
}
if (streamReconciliationInFlightRef.current && reason === "poll") {
return "unknown";
}
// Stale finish/error from a prior interrupt must not joinStream over a
// turn the user already started (协助卡提交后的实时流会被打断并攒批)。
if (
(reason === "finish" || reason === "error") &&
(sendInFlightRef.current || threadStreamRef.current?.isLoading)
) {
return "rejoined";
}
streamReconciliationInFlightRef.current = true;
const epochAtStart = sendEpochRef.current;
try {
const client = getAPIClient(isMock);
let runId = activeRunIdRef.current ?? readActiveRunId(tid);
const terminalRunId = runId;
const applyTerminalState = async (status: string) => {
const finalState = await client.threads.getState(tid);
if (
shouldDiscardTerminalOverlay({
epochAtStart,
currentEpoch: sendEpochRef.current,
sendInFlight: sendInFlightRef.current,
terminalRunId,
currentRunId: activeRunIdRef.current,
})
) {
return;
}
clearActiveRunId(tid);
activeRunIdRef.current = null;
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
setThreadValueOverrides(
finalState.values as Partial<AgentThreadState>,
);
if (status === "success") {
setOptimisticMessages([]);
if (reason !== "finish") {
listeners.current.onFinish?.(
finalState.values as AgentThreadState,
);
void queryClient.invalidateQueries({
queryKey: ["threads", "search"],
});
}
} else if (reason !== "finish") {
toast.error(
"本轮生成未能完成,已同步当前对话内容,可重新发起请求。",
);
}
};
if (runId) {
try {
const run = await client.runs.get(tid, runId);
if (TERMINAL_RUN_STATUSES.has(run.status)) {
await applyTerminalState(run.status);
return "terminal";
}
} catch {
runId = null;
}
}
if (!runId) {
try {
const runs = await client.runs.list(tid, { limit: 20 });
const inflight = runs.find((item) =>
isInflightRunStatus(item.status),
);
runId = inflight?.run_id ?? null;
if (runId) {
activeRunIdRef.current = runId;
}
} catch {
runId = null;
}
}
if (!runId) {
setStreamHoldOpen(false);
activeRunIdRef.current = null;
return "unknown";
}
persistActiveRunId(tid, runId);
setStreamHoldOpen(true);
const streamAboutToEnd = reason === "finish" || reason === "error";
if (
threadStreamRef.current?.isLoading &&
!streamAboutToEnd &&
reason !== "conflict"
) {
if (
Date.now() - lastStreamActivityAtRef.current <
STREAM_RECONNECT_IDLE_MS
) {
return "rejoined";
}
// Local SSE reader stalled while the backend run is still alive.
// SDK stop() cancels the server run if lg:stream is set — hide it
// first, abort only the browser reader, then join the live run.
if (reason === "poll") {
if (
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error(
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
);
}
return "rejoined";
}
streamReconnectAttemptsRef.current += 1;
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
if (!stream) {
return "unknown";
}
runMetadataStorageRef.current?.removeItem(`lg:stream:${tid}`);
void Promise.resolve(stream.stop())
.catch(() => undefined)
.finally(() => {
if (threadIdRef.current !== tid) return;
persistActiveRunId(tid, runId);
void stream.joinStream(runId).catch(() => undefined);
});
return "rejoined";
}
}
if (reason !== "conflict") {
if (
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error(
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
);
}
return "rejoined";
}
streamReconnectAttemptsRef.current += 1;
}
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
if (!stream) {
return "unknown";
}
void stream.joinStream(runId).catch(() => {
/* poll retries while the run stays non-terminal */
});
return "rejoined";
} finally {
streamReconciliationInFlightRef.current = false;
}
},
[isMock, onStreamThreadId, queryClient],
);
confirmOrRejoinRef.current = confirmOrRejoin;
useEffect(() => {
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
@@ -830,121 +1125,24 @@ export function useThreadStream({
}
let disposed = false;
let refreshTimer: number | null = null;
const reconcileRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
) {
if (disposed) return;
const dropped = !threadStreamRef.current?.isLoading;
const idleFor = Date.now() - lastStreamActivityAtRef.current;
if (!dropped && idleFor < STREAM_RECONCILIATION_IDLE_MS) {
return;
}
const storage = runMetadataStorageRef.current;
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
if (!runId) return;
streamReconciliationInFlightRef.current = true;
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed) {
if (dropped && idleFor < 800) {
return;
}
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
// ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend
// continuing the run.
//
// SDK ``stop()`` is *not* a local-only abort: its onStop hook calls
// ``runs.cancel`` and deletes ``lg:stream:{threadId}``, which is
// exactly "答一半就不答了". Hide the run id first so stop() only
// aborts the browser reader, then join the still-running server run.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId);
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) {
return;
}
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
}
if (!stored && !streamHoldOpen) {
return;
}
streamReconnectAttemptsRef.current += 1;
// Avoid enqueueing another reconnect before this new reader has had
// enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
storage?.removeItem(`lg:stream:${activeThreadId}`);
void Promise.resolve(stream.stop())
.catch(() => {
// Stopping a stale local reader is best-effort; joining is still
// useful if the SDK already considers the stream stopped.
})
.finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return;
try {
storage?.setItem(`lg:stream:${activeThreadId}`, runId);
} catch {
/* noop */
}
void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt.
});
});
return;
}
// The server is already terminal, so the existing SSE listener cannot
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event or already reported a transport
// error and dropped ``isLoading``.
const finalState = await client.threads.getState(activeThreadId);
if (disposed) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") {
setOptimisticMessages([]);
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else {
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
}
// Changing the controlled thread id clears only the local SDK stream
// (it does not cancel the already-terminal backend run). Switching it
// back immediately makes useStream fetch the persisted messages, so
// the page exits the false loading state without a manual refresh.
setOnStreamThreadId(null);
refreshTimer = window.setTimeout(() => {
if (!disposed && threadIdRef.current === activeThreadId) {
setOnStreamThreadId(activeThreadId);
}
}, 0);
} catch {
// A transient status lookup failure must not disrupt a real stream;
// the next polling interval will try again if it is still quiet.
} finally {
if (!disposed) {
streamReconciliationInFlightRef.current = false;
}
}
await confirmOrRejoin("poll");
};
const interval = window.setInterval(
@@ -955,11 +1153,8 @@ export function useThreadStream({
return () => {
disposed = true;
window.clearInterval(interval);
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
}
};
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
}, [confirmOrRejoin, onStreamThreadId, streamHoldOpen, thread.isLoading]);
// Reset thread-local pending UI state when the caller actually switches AWAY
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
@@ -989,7 +1184,11 @@ export function useThreadStream({
}
startedRef.current = false;
sendInFlightRef.current = false;
sendEpochRef.current += 1;
prevMsgCountRef.current = 0;
optimisticKnownHumanIdsRef.current = new Set();
activeRunIdRef.current = null;
setStreamHoldOpen(false);
setOptimisticMessages([]);
setIsUploading(false);
setThreadValueOverrides({});
@@ -1074,15 +1273,22 @@ export function useThreadStream({
threadValueOverrides.title_provisional,
]);
// Clear optimistic when server messages arrive (count increases)
// Clear optimistic only when the server echoes a *new* human message.
// After 用户协助, a late checkpoint / tool result can bump length without
// the new answer yet — clearing then makes the bubble flash and vanish.
useEffect(() => {
if (optimisticMessages.length === 0) {
return;
}
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
hasServerEchoedOptimisticHuman(
thread.messages,
optimisticKnownHumanIdsRef.current,
)
) {
setOptimisticMessages([]);
}
}, [thread.messages.length, optimisticMessages.length]);
}, [thread.messages, optimisticMessages.length]);
// Bind a just-sent question's ask time to the server-assigned human message
// id once it appears. Persists to localStorage so the time shows on every
@@ -1115,22 +1321,36 @@ export function useThreadStream({
return;
}
sendInFlightRef.current = true;
sendEpochRef.current += 1;
// Drop frozen checkpoint messages so the new SSE can paint live tokens.
// Keep title / title_provisional overlays from the prior reconcile.
setThreadValueOverrides((prev) => {
if (!("messages" in prev)) {
return prev;
}
const { messages: _dropped, ...rest } = prev;
return rest;
});
setStreamHoldOpen(false);
const text = message.text.trim();
// Wall-clock time the question was asked. Stored on the human message so
// the chat can show "几点提问" and have it survive a reload (it round-trips
// through the checkpoint inside additional_kwargs, same as `files`).
const clientTs = new Date().toISOString();
const knownHumanIds = collectHumanMessageIds(
thread.messages,
Array.isArray(thread.values?.messages)
? (thread.values.messages as Message[])
: undefined,
);
// Remember which human messages already exist so the reconciliation
// effect can bind ``clientTs`` to the new one once the server returns it.
pendingAskTimeRef.current = {
ts: clientTs,
known: new Set(
thread.messages
.filter((m) => m.type === "human" && m.id)
.map((m) => m.id as string),
),
known: knownHumanIds,
};
optimisticKnownHumanIdsRef.current = knownHumanIds;
// Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length;
@@ -1355,6 +1575,18 @@ export function useThreadStream({
error instanceof Error ? error.message : String(error);
const friendly = friendlySendLimitMessage(message);
if (friendly) {
setIsUploading(false);
if (isActiveRunConflictError(message)) {
setOptimisticMessages([]);
setStreamHoldOpen(true);
const result = await confirmOrRejoin("conflict");
toast.info(
result === "rejoined"
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
: friendly,
);
return;
}
setOptimisticMessages((messages) => {
const human = messages.find((item) => item.type === "human");
const kept = human ? [human] : [];
@@ -1368,7 +1600,6 @@ export function useThreadStream({
} as Message,
];
});
setIsUploading(false);
return;
}
setOptimisticMessages([]);
@@ -1378,31 +1609,58 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin],
);
// Merge thread with optimistic messages for display
const mergedValues =
Object.keys(threadValueOverrides).length > 0
? {
...thread.values,
...threadValueOverrides,
const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current;
const runId =
activeRunIdRef.current ?? (tid ? readActiveRunId(tid) : null);
setStreamHoldOpen(false);
activeRunIdRef.current = null;
if (tid && runId) {
persistActiveRunId(tid, runId);
}
: thread.values;
try {
await Promise.resolve(threadStreamRef.current?.stop());
} catch {
/* local abort is best-effort */
}
if (tid && runId) {
try {
await getAPIClient(isMock).runs.cancel(tid, runId);
} catch {
/* already stopped / missing */
}
clearActiveRunId(tid);
}
}, [isMock]);
// Merge thread with optimistic messages for display. While a live SSE is
// open, never let a stale getState snapshot's `messages` win — that is what
// made 协助卡提交后「发送中」攒到结束才一口气出字。
const mergedValues =
mergeStreamDisplayValues(
thread.values as AgentThreadState | undefined,
threadValueOverrides,
thread.isLoading,
) ?? thread.values;
const mergedMessages =
Array.isArray(mergedValues.messages)
Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const mergedThread =
optimisticMessages.length > 0 || mergedValues !== thread.values
? ({
const mergedThread = {
...thread,
values: mergedValues,
messages: [...mergedMessages, ...optimisticMessages],
} as typeof thread)
: thread;
messages:
optimisticMessages.length > 0
? [...mergedMessages, ...optimisticMessages]
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop,
} as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
test("live streaming ignores frozen checkpoint messages so tokens can paint", () => {
const live = {
title: "live",
messages: [{ type: "human", id: "h1", content: "A" }],
};
const overrides = {
title: "frozen",
messages: [{ type: "human", id: "h1", content: "A" }],
};
const duringStream = mergeStreamDisplayValues(live, overrides, true);
assert.deepEqual(duringStream?.messages, live.messages);
assert.equal(duringStream?.title, "frozen");
const afterStream = mergeStreamDisplayValues(live, overrides, false);
assert.equal(afterStream?.title, "frozen");
assert.deepEqual(afterStream?.messages, overrides.messages);
});
test("a later send discards the previous run's getState overlay", () => {
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 1,
currentEpoch: 2,
sendInFlight: false,
terminalRunId: "old-run",
currentRunId: "old-run",
}),
true,
);
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 2,
currentEpoch: 2,
sendInFlight: true,
terminalRunId: "old-run",
currentRunId: "old-run",
}),
true,
);
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 2,
currentEpoch: 2,
sendInFlight: false,
terminalRunId: "old-run",
currentRunId: "new-run",
}),
true,
);
assert.equal(
shouldDiscardTerminalOverlay({
epochAtStart: 1,
currentEpoch: 1,
sendInFlight: false,
terminalRunId: "run-1",
currentRunId: "run-1",
}),
false,
);
});
test("optimistic human stays until a new server human id appears", () => {
const known = collectHumanMessageIds([
{ type: "human", id: "h1", content: "first" } as Message,
]);
const afterToolOnly = [
{ type: "human", id: "h1", content: "first" },
{ type: "tool", id: "t1", content: "OK" },
] as Message[];
assert.equal(hasServerEchoedOptimisticHuman(afterToolOnly, known), false);
const afterEcho = [
...afterToolOnly,
{ type: "human", id: "h2", content: "按方案 A" },
] as Message[];
assert.equal(hasServerEchoedOptimisticHuman(afterEcho, known), true);
});
@@ -0,0 +1,98 @@
import type { Message } from "@langchain/langgraph-sdk";
/**
* After a run ends (especially an interrupt / 用户协助卡), `confirmOrRejoin`
* snapshots `threads.getState()` into an overlay so a premature SSE close can
* still show the checkpoint. That overlay must never win over a *new* live
* stream: `overrides.messages` would freeze the previous turn and dump the
* next answer only when the next `getState()` lands.
*/
export function mergeStreamDisplayValues<T extends Record<string, unknown>>(
live: T | undefined,
overrides: Partial<T>,
isLiveStreaming: boolean,
): T | undefined {
const overrideKeys = Object.keys(overrides);
if (overrideKeys.length === 0) {
return live;
}
if (isLiveStreaming) {
const { messages: _ignored, ...rest } = overrides as Partial<T> & {
messages?: unknown;
};
if (Object.keys(rest).length === 0) {
return live;
}
return { ...(live as T), ...rest };
}
return { ...(live as T), ...overrides };
}
export function shouldDiscardTerminalOverlay(opts: {
epochAtStart: number;
currentEpoch: number;
sendInFlight: boolean;
terminalRunId: string | null;
currentRunId: string | null;
}): boolean {
if (opts.currentEpoch !== opts.epochAtStart) {
return true;
}
if (opts.sendInFlight) {
return true;
}
if (
opts.currentRunId &&
opts.terminalRunId &&
opts.currentRunId !== opts.terminalRunId
) {
return true;
}
return false;
}
export function collectHumanMessageIds(
...groups: Array<Message[] | undefined>
): Set<string> {
const ids = new Set<string>();
for (const messages of groups) {
if (!Array.isArray(messages)) continue;
for (const message of messages) {
if (
message?.type === "human" &&
typeof message.id === "string" &&
message.id &&
!message.id.startsWith("opt-")
) {
ids.add(message.id);
}
}
}
return ids;
}
/**
* Drop the optimistic human bubble only when the server has echoed a *new*
* human message. A late checkpoint / tool result that merely increases
* `messages.length` must not make the user's input vanish.
*/
export function hasServerEchoedOptimisticHuman(
serverMessages: Message[] | undefined,
knownHumanIds: Set<string>,
): boolean {
if (!Array.isArray(serverMessages)) {
return false;
}
for (const message of serverMessages) {
if (
message?.type === "human" &&
typeof message.id === "string" &&
message.id &&
!message.id.startsWith("opt-") &&
!knownHumanIds.has(message.id)
) {
return true;
}
}
return false;
}
+48 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { BotIcon, Compass, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
@@ -11,6 +11,11 @@ import { Button } from "@/components/ui/button";
import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
import {
AgentGuide,
isAgentGuideEnvironment,
startAgentGuide,
} from "@/components/workspace/agents/agent-guide";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
@@ -559,6 +564,10 @@ export default function AgentChatPage({
// 3qfx/xdfx(taskBind)不展示闸门,新建对话进问答页后由右上角「开始回答」按钮手动发出开场信息。
const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
// 智能体使用引导(chat 阶段):仅普通智能体对话页 —— 排除通用问答原地嵌入
// (embeddedAgentId)、任务深链工作区(taskGate/taskBind)等复用本页的场景。
const agentGuideEligible =
!!showAgentList && !isEmbedded && !taskCtxOn && isAgentGuideEnvironment();
const status = thread.error
? "error"
@@ -575,7 +584,14 @@ export default function AgentChatPage({
panel stays rightmost. */}
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
{/* 最左:智能体列表栏(仅独立整页问答开启)。顶部下拉切换 我的/广场。 */}
{showAgentList && <AgentChatSidebar activeAgentId={agent_id} />}
{showAgentList && (
<div
data-agent-guide="chat-sidebar"
className="flex h-full shrink-0"
>
<AgentChatSidebar activeAgentId={agent_id} />
</div>
)}
<div className="min-h-0 min-w-0 flex-1">
<ChatBox threadId={threadId}>
{/* Main conversation column. `relative` hosts the floating
@@ -598,6 +614,20 @@ export default function AgentChatPage({
</div>
<div className="flex items-center">
{/* 智能体使用引导(chat 阶段)手动重放入口。 */}
{agentGuideEligible && (
<Tooltip content="重新播放智能体使用引导">
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground mr-1"
onClick={() => startAgentGuide("chat")}
>
<Compass className="size-4" />
使用引导
</Button>
</Tooltip>
)}
{/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */}
{isTemplateBuilder && (
<div className="mr-2 flex items-center gap-1.5">
@@ -688,13 +718,17 @@ export default function AgentChatPage({
className="mb-7"
/>
<div className="w-full max-w-(--container-width-md)">
<div data-agent-guide="chat-questions" className="mb-3">
<AgentQuestionChips
className="mb-3"
questions={commonQuestions}
disabled={isDemoMode}
onAsk={(text) => handleSubmit({ text, files: [] })}
/>
<div className="min-w-0 flex-1">
</div>
<div
className="min-w-0 flex-1"
data-agent-guide="chat-input"
>
<InputBox
className="w-full"
isNewThread
@@ -756,7 +790,10 @@ export default function AgentChatPage({
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
<div
className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)"
data-agent-guide="chat-input"
>
<TodoList
className="bg-background/5 mb-2"
todos={thread.values.todos ?? []}
@@ -805,6 +842,10 @@ export default function AgentChatPage({
</div>
{/* Right: agent detail panel — always the rightmost column. */}
<div
data-agent-guide="chat-detail"
className="flex h-full shrink-0"
>
<AgentDetailPanel
agent={agent}
agentName={agentDisplayName}
@@ -815,6 +856,8 @@ export default function AgentChatPage({
compactAppearanceActions={compactAppearanceActions}
/>
</div>
</div>
<AgentGuide stage="chat" eligible={agentGuideEligible} />
{/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */}
{isTemplateBuilder && (
+22 -2
View File
@@ -34,11 +34,13 @@ import {
} from "@/components/workspace/appearance-controls";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react";
@@ -665,6 +667,17 @@ export default function ChatPage() {
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
const compactActionsHidden = isHideChromeActive();
// 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
// 全局 chrome,但不该在外部系统里弹引导。
const compactGuideEligible =
!compactChrome &&
!fullChatMode &&
!isChatsIframeWorkspacePath(pathname) &&
!compactActionsHidden &&
!env.FORCE_COLLAPSED_SIDEBAR &&
!isEmbedActive() &&
!isWujieEnv();
return (
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
@@ -801,7 +814,10 @@ export default function ChatPage() {
/>
))}
{mounted ? (
<div className="w-full -translate-y-4">
<div
className="w-full -translate-y-4"
data-compact-guide="prompt-input"
>
<InputBox
className="w-full"
isNewThread={isLandingState}
@@ -886,6 +902,10 @@ export default function ChatPage() {
className="top-16 right-0 bottom-48 left-0"
/>
)}
<CompactModeGuide
eligible={compactGuideEligible}
autoStart={mounted && isLandingState}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
+32 -3
View File
@@ -3,6 +3,7 @@
import {
ArrowLeftIcon,
CheckCircleIcon,
Compass,
SaveIcon,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
@@ -39,6 +40,11 @@ import {
import { ThreadContext } from "@/components/workspace/messages/context";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import {
AgentGuide,
isAgentGuideEnvironment,
startAgentGuide,
} from "@/components/workspace/agents/agent-guide";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
import {
@@ -407,6 +413,17 @@ export default function NewAgentPage() {
</Button>
<h1 className="text-sm font-semibold">创建智能体</h1>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title="重新播放创建引导"
onClick={() => startAgentGuide("create")}
>
<Compass className="mr-1.5 h-4 w-4" />
使用引导
</Button>
{agent ? (
<Button
variant="outline"
@@ -417,6 +434,7 @@ export default function NewAgentPage() {
开始对话
</Button>
) : null}
</div>
</header>
<ResizablePanelGroup
@@ -442,7 +460,10 @@ export default function NewAgentPage() {
onSetupAgentReject={handleRejectSetupAgent}
/>
</div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div
className="bg-background shrink-0 space-y-2 border-t px-4 py-4"
data-agent-guide="create-chat-input"
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
当前对话模型
@@ -525,6 +546,7 @@ export default function NewAgentPage() {
</p>
</div>
<div className="space-y-5" data-agent-guide="create-basic">
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
@@ -549,7 +571,9 @@ export default function NewAgentPage() {
className="min-h-24 resize-none"
/>
</div>
</div>
<div className="space-y-5" data-agent-guide="create-model">
<div className="space-y-2">
<label className="text-sm font-medium">模型</label>
{isModelsLoading || models.length === 0 ? (
@@ -606,15 +630,18 @@ export default function NewAgentPage() {
文件右上角会出现"发布入库"按钮,可一键发送到知识入库接口。
</p>
</div>
</div>
<div data-agent-guide="create-knowledge">
<LlmWikiKnowledgeSelector
source="all"
value={selectedKnowledgeBaseIds}
onChange={setSelectedKnowledgeBaseIds}
variant="panel"
/>
</div>
<div className="space-y-2">
<div className="space-y-2" data-agent-guide="create-soul">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下智能体的人设、边界、工作方式和输出要求"
@@ -624,7 +651,7 @@ export default function NewAgentPage() {
/>
</div>
<div className="space-y-2">
<div className="space-y-2" data-agent-guide="create-skills">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">技能</label>
<span className="text-muted-foreground text-xs">
@@ -675,6 +702,7 @@ export default function NewAgentPage() {
className="w-full"
onClick={() => void handleSaveAgent()}
disabled={busy || isSkillsLoading}
data-agent-guide="create-save-btn"
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存智能体" : "创建智能体"}
@@ -683,6 +711,7 @@ export default function NewAgentPage() {
</aside>
</ResizablePanel>
</ResizablePanelGroup>
<AgentGuide stage="create" eligible={isAgentGuideEnvironment()} />
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
@@ -30,7 +30,8 @@ import { cn } from "@/lib/utils";
/**
* 舆情分析智能体对话页:一个前端虚拟智能体(不在后端 /api/agents 落库),
* 问答直连外部 AG-UI 网关(地址 + Basic Auth 在 runtime-config.js 配置)。
* 问答经 Gateway POST /api/sentiment-agent/stream 转发外部 AG-UI 网关
* (地址 + Basic Auth 在 runtime-config.js,随请求传给后台)。
* 页面复用普通问答的消息渲染(MessageList),输入框只保留发送按钮;
* 会话只存在当前标签页 sessionStorage,不创建后端 thread。
*/
+1 -1
View File
@@ -325,7 +325,7 @@ export default function StrategyRoutes() {
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
{/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */}
{/* 舆情分析虚拟智能体:经 Gateway 转发外部 AG-UI 网关的专用对话页。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
+2 -2
View File
@@ -478,8 +478,8 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部
AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
+18
View File
@@ -433,3 +433,21 @@
--td-scrollbar-hover-color: rgba(197, 207, 224, 0.36);
--td-scroll-track-color: #122347;
}
/*
* Guide overlays vs. Radix modal dialogs.
*
* Radix modal dialogs (react-remove-scroll) set `pointer-events: none` on
* <body> while open. TDesign Guide mounts its mask / highlight / tooltip
* directly under <body>, so they inherit `none` and every guide button
* (上一步/下一步/跳过/完成) goes dead whenever a modal dialog is open under
* the guide — e.g. the compact-mode tour opening the ManagePromptsDialog.
* Re-enable pointer events on the guide layers explicitly; without a modal
* open this matches the inherited default and changes nothing.
*/
.t-guide__overlay,
.t-guide__highlight,
.t-guide__popup,
.t-guide__wrapper {
pointer-events: auto;
}
+2 -1
View File
@@ -347,6 +347,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Compact Menu Overrides** (`/api/compact-menu-overrides`) | Compact/sidebar-summary left-nav overlay (简介模式菜单), **global/shared**. Two independent sets keyed by `menu_type` (`A` default / `B`); saving one type does not wipe the other. Type B rows use a `B::` node_id prefix so they stay unique on databases that still use `node_id` as the sole PK. `GET ?menu_type=` lists one type (open); `PUT {menu_type, overrides}` admin-only replaces that type. Frontend: `MenuManagementPage` 简介模式 type switcher + `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. Tests: `tests/test_compact_menu_overrides.py`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
| **Sentiment Agent Proxy** (`/api/sentiment-agent`) | BFF for the frontend 舆情分析 virtual agent. `POST /stream` accepts the AG-UI body plus `apiUrl`/`authUsername`/`authPassword` from `runtime-config.js`, forwards to that URL with TLS verify off, and byte-pipes upstream SSE. Connection/HTTP failures return the full error in `detail`; mid-stream failures emit AG-UI `RUN_ERROR`. Distinct from `skill-packages/sentiment-analysis.skill`. Tests: `tests/test_sentiment_agent_proxy.py`. |
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business/purpose/host), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
@@ -737,7 +738,7 @@ MySQL index script synchronized.
or leaving the prompt blank emits no block. Tests:
`tests/test_es_query_routing_prompt.py`.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the existing frontend virtual-agent path. Its self-contained `config/sentiment-agent.json` owns the external AG-UI address; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the frontend virtual-agent chat page. The chat page goes through Gateway `POST /api/sentiment-agent/stream` (runtime-config URL/Basic Auth in the body, TLS verify off, raw AG-UI SSE passthrough). The skill package's self-contained `config/sentiment-agent.json` owns the external AG-UI address for agent-skill callers; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
#### Skill Address Management (技能地址管理 — URL/IP 批量替换)
@@ -163,6 +163,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET /taskAnalyseSearch/cop-task-three-list` | TaskCOP compatibility API (authenticated): paginated task list for the situation-overview sentiment page (`content`, `taskStatus`, `taskDirection`, `startTime`, `endTime`) |
| `GET/POST/PUT /api/task-reports/by-task/{task_id}` | Shared task-scoped situation-report detail. Query, first save, and full update all use the legacy-compatible `{state, msg, data:[{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}]}` shape; `contentJson` is preserved as a JSON string. Saving a non-empty report automatically moves the matching TaskCOP task to status `25` (the legacy list's “已完成” state). |
| `POST /api/task-reports/import/by-task/{task_id}` | TaskCOP detail import (authenticated): accept a `sq-report-mock.json`-compatible `data`/`records` array and atomically replace the selected task's full report. The path task id overrides task ids inside the file. |
| `POST /api/sentiment-agent/stream` | Authenticated BFF for the frontend sentiment-analysis virtual agent. Forwards the AG-UI JSON body to the `apiUrl` supplied from `runtime-config.js` (Basic Auth from the same payload). TLS certificate verification is off. Upstream SSE is piped through unchanged; connection/HTTP failures return the complete error in `detail`. |
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges local Vite origins `http://localhost|127.0.0.1:{5173,5174,3000,8080}`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. This keeps a Vite server that falls back from 5173 to 5174 (or the reverse) working after a backend restart. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
@@ -84,6 +84,7 @@ from app.gateway.routers import (
runs,
scheduled_tasks,
sensitive_words,
sentiment_agent,
skill_addresses,
skills,
suggestions,
@@ -1587,6 +1588,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
# Sentiment-analysis virtual agent AG-UI SSE proxy at /api/sentiment-agent
app.include_router(sentiment_agent.router)
# Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
app.include_router(skill_addresses.router)
@@ -0,0 +1,220 @@
"""舆情分析虚拟智能体的 AG-UI SSE 反向代理。
前端对话页不直连外部网关(避免浏览器 CORS / 混合内容),改为把
``runtime-config.js`` 里的 ``apiUrl`` / Basic Auth 随 AG-UI 请求体传给本路由。
Gateway 关闭 TLS 校验后按传入地址转发上游 SSE,字节级透传给前端。
连接失败或上游非 2xx 时,把完整错误信息放进 HTTP ``detail``(流已开始则发
AG-UI ``RUN_ERROR`` 帧),前端原样展示。
Routes (prefix ``/api/sentiment-agent``):
POST /stream 透传上游 ``text/event-stream``
"""
from __future__ import annotations
import base64
import json
import logging
import ssl
from collections.abc import AsyncIterator, Mapping
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sentiment-agent", tags=["sentiment-agent"])
_MAX_ERROR_BODY_CHARS = 32_768
class SentimentAgentStreamRequest(BaseModel):
"""前端传入的 AG-UI 体 + runtime-config 上游地址/凭据。"""
model_config = ConfigDict(extra="ignore")
apiUrl: str = Field(default="", min_length=0, max_length=2048)
authUsername: str = Field(default="", max_length=512)
authPassword: str = Field(default="", max_length=512)
threadId: str = Field(..., min_length=1, max_length=256)
runId: str = Field(..., min_length=1, max_length=256)
state: dict[str, Any] = Field(default_factory=dict)
tools: list[Any] = Field(default_factory=list)
context: list[Any] = Field(default_factory=list)
forwardedProps: dict[str, Any] = Field(default_factory=dict)
messages: list[Any] = Field(default_factory=list)
def _unverified_ssl_context() -> ssl.SSLContext:
"""Do not validate the upstream certificate (intranet / self-signed hosts)."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _build_http_client(timeout_seconds: float) -> httpx.AsyncClient:
"""Upstream client: skip TLS cert checks, ignore env proxy, no redirects."""
return httpx.AsyncClient(
verify=_unverified_ssl_context(),
trust_env=False,
follow_redirects=False,
timeout=httpx.Timeout(timeout_seconds, connect=30.0),
)
def _format_exception(exc: BaseException) -> str:
parts = [f"{type(exc).__name__}: {exc}"]
seen = {id(exc)}
cause: BaseException | None = exc.__cause__ or exc.__context__
while cause is not None and id(cause) not in seen:
seen.add(id(cause))
parts.append(f"Caused by {type(cause).__name__}: {cause}")
cause = cause.__cause__ or cause.__context__
return " | ".join(parts)
def _decode_body(content: bytes | None) -> str:
if not content:
return ""
text = content.decode("utf-8", errors="replace").strip()
if len(text) > _MAX_ERROR_BODY_CHARS:
return text[:_MAX_ERROR_BODY_CHARS] + f"\n…(truncated, total {len(text)} chars)"
return text
def format_upstream_error(
*,
kind: str,
url: str,
status: int | None = None,
headers: Mapping[str, str] | None = None,
body: str = "",
exc: BaseException | None = None,
) -> str:
"""Assemble a complete, user-visible upstream failure string."""
lines = [f"舆情分析上游调用失败({kind})", f"url={url}"]
if status is not None:
lines.append(f"http_status={status}")
if headers:
location = headers.get("location") or headers.get("Location")
content_type = headers.get("content-type") or headers.get("Content-Type")
if location:
lines.append(f"location={location}")
if content_type:
lines.append(f"content_type={content_type}")
if body:
lines.append(f"response={body}")
if exc is not None:
lines.append(_format_exception(exc))
request_obj = getattr(exc, "request", None)
method = getattr(request_obj, "method", None)
url_obj = getattr(request_obj, "url", None)
if method and url_obj is not None:
lines.append(f"request={method} {url_obj}")
return "\n".join(lines)
def _upstream_headers(username: str, password: str) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if username and password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
headers["Authorization"] = f"Basic {token}"
return headers
def _agui_payload(body: SentimentAgentStreamRequest) -> dict[str, Any]:
return {
"threadId": body.threadId,
"runId": body.runId,
"state": body.state,
"tools": body.tools,
"context": body.context,
"forwardedProps": body.forwardedProps,
"messages": body.messages,
}
def _run_error_frame(message: str) -> bytes:
payload = json.dumps({"type": "RUN_ERROR", "message": message}, ensure_ascii=False)
return f"data: {payload}\n\n".encode()
@router.post("/stream")
async def stream_sentiment_agent(
request: Request,
body: SentimentAgentStreamRequest,
) -> StreamingResponse:
api_url = (body.apiUrl or "").strip()
if not api_url:
raise HTTPException(status_code=400, detail="缺少上游地址 apiUrl(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。")
config = get_app_config().sentiment_agent
payload = _agui_payload(body)
headers = _upstream_headers(body.authUsername, body.authPassword)
client = _build_http_client(config.timeout_seconds)
upstream: httpx.Response | None = None
try:
upstream = await client.send(
client.build_request("POST", api_url, headers=headers, json=payload),
stream=True,
)
except httpx.RequestError as exc:
await client.aclose()
raise HTTPException(
status_code=502,
detail=format_upstream_error(kind="network", url=api_url, exc=exc),
) from exc
if upstream.status_code >= 400:
err_body = _decode_body(await upstream.aread())
err_headers = dict(upstream.headers)
status = upstream.status_code
await upstream.aclose()
await client.aclose()
raise HTTPException(
status_code=502,
detail=format_upstream_error(
kind="http",
url=api_url,
status=status,
headers=err_headers,
body=err_body,
),
)
async def pump() -> AsyncIterator[bytes]:
assert upstream is not None
try:
async for chunk in upstream.aiter_bytes():
if await request.is_disconnected():
break
if chunk:
yield chunk
except httpx.RequestError as exc:
yield _run_error_frame(format_upstream_error(kind="stream", url=api_url, exc=exc))
finally:
await upstream.aclose()
await client.aclose()
return StreamingResponse(
pump(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -1243,12 +1243,19 @@ async def sse_consumer(
The ``finally`` block implements ``on_disconnect`` semantics:
- ``cancel``: abort the background task on client disconnect.
- ``continue``: let the task run; events are discarded.
- ``continue``: let the task run so the client can ``joinStream``.
``is_disconnected()`` is only consulted in cancel mode. In continue
(resumable) mode a false-positive disconnect would *cleanly* end the
HTTP stream; the LangGraph SDK treats that as success, clears
``lg:stream:{threadId}``, and flips ``isLoading`` off — while the
backend run keeps the thread occupied, so the next send 409s.
A truly gone client fails on the next ``yield`` instead.
"""
last_event_id = request.headers.get("Last-Event-ID")
try:
async for entry in bridge.subscribe(record.run_id, last_event_id=last_event_id):
if await request.is_disconnected():
if record.on_disconnect == DisconnectMode.cancel and await request.is_disconnected():
break
if entry is HEARTBEAT_SENTINEL:
@@ -1261,6 +1268,9 @@ async def sse_consumer(
yield format_sse(entry.event, entry.data, event_id=entry.id or None)
except (ConnectionError, BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
logger.info("SSE client connection lost run_id=%s thread_id=%s", record.run_id, record.thread_id)
finally:
if record.status in (RunStatus.pending, RunStatus.running):
if record.on_disconnect == DisconnectMode.cancel:
+6 -1
View File
@@ -302,7 +302,7 @@ tools:
# preserved in the comments below (fill in the real values) and point
# `use` back to deerflow.community.configurable_search.tools:web_search_tool.
use: deerflow.community.ddg_search.tools:web_search_tool
enabled: true
enabled: false
max_results: 5
# ── configurable_search alternative (deployment-internal service) ──
# endpoint: "https://xxx.xxx.xxx/searchName"
@@ -575,6 +575,11 @@ log_system:
url: 'https://your-log-system.example.com'
label: '日志系统'
# 舆情分析虚拟智能体:前端改 dist/runtime-config.js 的地址/账号/密码,
# 随请求传给 Gateway 转发;这里只配上游超时。
sentiment_agent:
timeout_seconds: 300
task_deeplink:
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
@@ -27,6 +27,7 @@ from deerflow.config.model_config import ModelConfig
from deerflow.config.run_events_config import RunEventsConfig
from deerflow.config.runtime_paths import existing_project_file
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.sentiment_agent_config import SentimentAgentConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
@@ -148,6 +149,10 @@ class AppConfig(BaseModel):
knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)")
y_log: YLogConfig = Field(default_factory=YLogConfig, description="操作日志上报配置(前端经 /api/public/config/y-log 读取)")
sentiment_agent: SentimentAgentConfig = Field(
default_factory=SentimentAgentConfig,
description="舆情分析虚拟智能体上游代理超时(地址由前端 runtime-config.js 随请求传入)",
)
@classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -0,0 +1,19 @@
"""舆情分析虚拟智能体的上游代理配置。
前端从 ``runtime-config.js`` 读取 AG-UI 地址和 Basic Auth,随每次请求传给
Gateway;本段只保留上游超时,地址以请求体里的 ``apiUrl`` 为准。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class SentimentAgentConfig(BaseModel):
"""Gateway 转发舆情分析 AG-UI 流时的超时。"""
timeout_seconds: float = Field(
default=300,
gt=0,
description="上游连接与读取超时(秒);流式问答可能持续数分钟",
)
+5
View File
@@ -331,6 +331,11 @@ y_log:
enabled: true
write_url: https://ch1.b.uat.4.cn/api/y-log/write
# 舆情分析虚拟智能体:前端改 dist/runtime-config.js 的地址/账号/密码,
# 随请求传给 Gateway 转发;这里只配上游超时。
sentiment_agent:
timeout_seconds: 300
summarization:
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep