diff --git a/AGENTS.md b/AGENTS.md index 3ea99a4e..ec76e9c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-`). 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..finished`,中途刷新/离开不记忆)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;最后一步按钮文案改为「去创建智能体 →」,点完成即 `router.push` 到创建页衔接下一段;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. diff --git a/CLAUDE.md b/CLAUDE.md index e184f138..858c9eec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=&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`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 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`. - **实时并发监控 (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/` diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 10cc1276..6d6109ad 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -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", diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 2a4dad79..c5d66630 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -947,6 +947,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar "group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900", agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20", )} + data-agent-guide="agent-card" > {agent.featured_order != null ? ( @@ -1008,7 +1009,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar ); const listLayout = ( -
+
@@ -1093,7 +1097,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar ? "正在加载 SOUL.md..." : "输入 SOUL.md 内容" } - className="max-h-[45vh] min-h-48 resize-y text-xs" + className="max-h-[45vh] min-h-48 resize-y text-xs" />
diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index bf3e3751..ef2178b6 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -144,6 +144,8 @@ export function AgentDetailPanel({ onAskQuestion, disabled, defaultCollapsed, + collapsed: collapsedProp, + onCollapsedChange, showRecentChats, compactAppearanceActions, avatarSrc, @@ -163,6 +165,14 @@ export function AgentDetailPanel({ disabled?: boolean; /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ defaultCollapsed?: boolean; + /** + * 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部 + * 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的 + * 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。 + */ + collapsed?: boolean; + /** 受控折叠态变化回调(配合 `collapsed` 使用)。 */ + onCollapsedChange?: (collapsed: boolean) => void; /** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */ showRecentChats?: boolean; /** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */ @@ -187,7 +197,15 @@ export function AgentDetailPanel({ editIcon?: ReactNode; }) { const router = useRouter(); - const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); + // 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。 + const [internalCollapsed, setInternalCollapsed] = useState( + defaultCollapsed ?? false, + ); + const collapsed = collapsedProp ?? internalCollapsed; + const setCollapsed = (next: boolean) => { + if (collapsedProp === undefined) setInternalCollapsed(next); + onCollapsedChange?.(next); + }; const [mobileOpen, setMobileOpen] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); @@ -927,12 +945,14 @@ export function AgentDetailPanel({ const panelChrome = (collapseButton: ReactNode) => ( <>
- + {/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只 + 裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */} + {panelTitle ?? "智能体信息"} -
+
+
{headerActions}
- {headerActions} {compactAppearanceActions}
{collapseButton &&
{collapseButton}
} @@ -944,11 +964,37 @@ export function AgentDetailPanel({ return ( <> - {/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */} + {/* ── Desktop (lg+) ────────────────────────────────────────────────── */} {embedded ? ( + ) : collapsedProp !== undefined ? ( + /* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条; + 展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面 + header「新对话」按钮右侧。 */ +
+ +
) : collapsed ? (
diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index fd25317d..31421cee 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -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,10 +403,22 @@ export function AgentGallery() { {t.agents.description}

- +
+ + +
{/* Tabs */} @@ -421,7 +438,10 @@ export function AgentGallery() { >
- + 我的 {defaultSquare && (
{showSearchBar && ( - + } /> +
)} {publisher && (
@@ -593,6 +615,7 @@ export function AgentGallery() { )} +
); } diff --git a/frontend-web/src/components/workspace/agents/agent-guide.tsx b/frontend-web/src/components/workspace/agents/agent-guide.tsx new file mode 100644 index 00000000..78f82f50 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-guide.tsx @@ -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; + 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 = { + 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([]); + + 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 ( + + ); +} diff --git a/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx b/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx index f7fc3b22..2d9eecf3 100644 --- a/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx @@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog"; /** * 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents), - * 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage), + * 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage), * 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。 */ export function SentimentAgentCard({ diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index b1bcd45e..a8ff1097 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -152,6 +152,7 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { return (
{ 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" > diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index f2481704..5de1b271 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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") && (
diff --git a/frontend-web/src/components/workspace/code-editor.tsx b/frontend-web/src/components/workspace/code-editor.tsx index c4ce96bc..05e889fe 100644 --- a/frontend-web/src/components/workspace/code-editor.tsx +++ b/frontend-web/src/components/workspace/code-editor.tsx @@ -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 ? (