From 62a3faa7c8cd8b3726ee136755a28bf4b94b0436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 25 Aug 2026 18:26:48 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E8=88=86=E6=83=85=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=99=BA=E8=83=BD=E4=BD=93=E6=94=B9=E4=B8=BA=E7=BB=8F?= =?UTF-8?q?=20Gateway=20=E8=BD=AC=E5=8F=91=E5=A4=96=E9=83=A8=20AG-UI=20?= =?UTF-8?q?=E7=BD=91=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端不再直连上游 SSE,改为 POST /api/sentiment-agent/stream 由后台代理转发,支持关闭 TLS 校验并回传完整错误信息。 Co-authored-by: Cursor --- AGENTS.md | 2 +- frontend-web/public/runtime-config.js | 4 +- .../workspace/agents/sentiment-agent-card.tsx | 2 +- frontend-web/src/core/sentiment-agent/api.ts | 51 +++- .../src/core/sentiment-agent/config.ts | 26 +-- .../use-sentiment-agent-chat.ts | 2 +- .../src/pages/SentimentAgentChatPage.tsx | 3 +- frontend-web/src/pages/StrategyRoutes.tsx | 2 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 4 +- offline-backend-20260512/backend/CLAUDE.md | 3 +- offline-backend-20260512/backend/README.md | 1 + .../backend/app/gateway/app.py | 144 ++++++------ .../app/gateway/routers/sentiment_agent.py | 220 ++++++++++++++++++ offline-backend-20260512/backend/config.yaml | 5 + .../harness/deerflow/config/app_config.py | 35 +-- .../deerflow/config/sentiment_agent_config.py | 19 ++ offline-backend-20260512/config.yaml | 5 + 17 files changed, 398 insertions(+), 130 deletions(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/sentiment_agent.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/sentiment_agent_config.py diff --git a/AGENTS.md b/AGENTS.md index ff3dad8a..c66992d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ 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). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. -- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 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/`。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. diff --git a/frontend-web/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/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/core/sentiment-agent/api.ts b/frontend-web/src/core/sentiment-agent/api.ts index 7bf1ca76..7ea08af7 100644 --- a/frontend-web/src/core/sentiment-agent/api.ts +++ b/frontend-web/src/core/sentiment-agent/api.ts @@ -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 { + 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 { 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("舆情分析服务未返回流式响应。"); diff --git a/frontend-web/src/core/sentiment-agent/config.ts b/frontend-web/src/core/sentiment-agent/config.ts index ceca9235..20b4616b 100644 --- a/frontend-web/src/core/sentiment-agent/config.ts +++ b/frontend-web/src/core/sentiment-agent/config.ts @@ -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 { - const { authUsername, authPassword } = readSentimentAgentEndpoint(); - const headers: Record = { - "Content-Type": "application/json", - Accept: "text/event-stream", - }; - if (authUsername && authPassword) { - headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`; - } - return headers; -} diff --git a/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts b/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts index a9417ba1..1e5e224a 100644 --- a/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts +++ b/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts @@ -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) { diff --git a/frontend-web/src/pages/SentimentAgentChatPage.tsx b/frontend-web/src/pages/SentimentAgentChatPage.tsx index 2dd92a99..4982cc3e 100644 --- a/frontend-web/src/pages/SentimentAgentChatPage.tsx +++ b/frontend-web/src/pages/SentimentAgentChatPage.tsx @@ -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。 */ diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 792017cd..873bcd33 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -325,7 +325,7 @@ export default function StrategyRoutes() { } /> } /> - {/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */} + {/* 舆情分析虚拟智能体:经 Gateway 转发外部 AG-UI 网关的专用对话页。 */} } /> - {/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部 - AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */} + {/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发 + 外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */} AsyncGenerator[None, None]: async with langgraph_runtime(app): logger.info("LangGraph runtime initialised") - # Run Alembic migrations before anything else touches the schema. - await _run_db_migrations() - - # Local Wiki mirror/index scans are opt-in and run outside request - # handling. Per-library database leases keep multi-worker startup safe. - app.state.llmwiki_index_scheduler_task = None - local_wiki_index = app.state.config.llmwiki.local_wiki_index - if ( - local_wiki_index.enabled - and local_wiki_index.auto_sync - and getattr(app.state, "llmwiki_sync_service", None) is not None - ): - from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop - - app.state.llmwiki_index_scheduler_task = asyncio.create_task( - local_wiki_index_scheduler_loop(app) - ) + # Run Alembic migrations before anything else touches the schema. + await _run_db_migrations() + + # Local Wiki mirror/index scans are opt-in and run outside request + # handling. Per-library database leases keep multi-worker startup safe. + app.state.llmwiki_index_scheduler_task = None + local_wiki_index = app.state.config.llmwiki.local_wiki_index + if ( + local_wiki_index.enabled + and local_wiki_index.auto_sync + and getattr(app.state, "llmwiki_sync_service", None) is not None + ): + from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop + + app.state.llmwiki_index_scheduler_task = asyncio.create_task( + local_wiki_index_scheduler_loop(app) + ) # ?? recall_budget cache?? DB ????,? get_effective_memory_config ????? try: @@ -1083,26 +1084,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ) ensure_deep_research_collector_agent() - except Exception: - logger.exception( - "Deep-research collector agent seeding failed at startup (non-fatal)" - ) - - # 内置「强制检索输出助手」:必须在 _sync_legacy_agents 之前补建, - # 使首次启动就能在智能体管理/广场中使用。每轮检索门禁由 harness - # 的 ForcedResearchMiddleware 执行,而不是仅依赖 SOUL 提示词。 - try: - from app.gateway.routers._forced_research_seed import ( - ensure_forced_research_agent, - ) - - ensure_forced_research_agent() - except Exception: - logger.exception( - "Forced-research responder agent seeding failed at startup (non-fatal)" - ) - - # ?????????????????3Q/6BF/7BF/8BF??agent/chain ????? + except Exception: + logger.exception( + "Deep-research collector agent seeding failed at startup (non-fatal)" + ) + + # 内置「强制检索输出助手」:必须在 _sync_legacy_agents 之前补建, + # 使首次启动就能在智能体管理/广场中使用。每轮检索门禁由 harness + # 的 ForcedResearchMiddleware 执行,而不是仅依赖 SOUL 提示词。 + try: + from app.gateway.routers._forced_research_seed import ( + ensure_forced_research_agent, + ) + + ensure_forced_research_agent() + except Exception: + logger.exception( + "Forced-research responder agent seeding failed at startup (non-fatal)" + ) + + # ?????????????????3Q/6BF/7BF/8BF??agent/chain ????? try: business_mapping_store = getattr(app.state, "business_mapping_store", None) if business_mapping_store is not None: @@ -1202,22 +1203,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: except Exception: logger.exception("Scheduled task service failed to start") - yield - - llmwiki_index_scheduler_task = getattr(app.state, "llmwiki_index_scheduler_task", None) - if llmwiki_index_scheduler_task is not None: - llmwiki_index_scheduler_task.cancel() - try: - await llmwiki_index_scheduler_task - except asyncio.CancelledError: - pass - except Exception: - logger.exception("Local Wiki index scheduler shutdown error") - llmwiki_index_jobs = list(getattr(app.state, "llmwiki_index_jobs", set())) - for job in llmwiki_index_jobs: - job.cancel() - if llmwiki_index_jobs: - await asyncio.gather(*llmwiki_index_jobs, return_exceptions=True) + yield + + llmwiki_index_scheduler_task = getattr(app.state, "llmwiki_index_scheduler_task", None) + if llmwiki_index_scheduler_task is not None: + llmwiki_index_scheduler_task.cancel() + try: + await llmwiki_index_scheduler_task + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Local Wiki index scheduler shutdown error") + llmwiki_index_jobs = list(getattr(app.state, "llmwiki_index_jobs", set())) + for job in llmwiki_index_jobs: + job.cancel() + if llmwiki_index_jobs: + await asyncio.gather(*llmwiki_index_jobs, return_exceptions=True) # ?? curator ??? curator_task = getattr(app.state, "curator_scheduler_task", None) @@ -1502,10 +1503,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # MCP API is mounted at /api/mcp app.include_router(mcp.router) - # DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora). - app.include_router(llmwiki.router) - app.include_router(llmwiki_index.router) - app.include_router(external_llmwiki.router) + # DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora). + app.include_router(llmwiki.router) + app.include_router(llmwiki_index.router) + app.include_router(external_llmwiki.router) # Memory API is mounted at /api/memory app.include_router(memory.router) @@ -1580,17 +1581,20 @@ 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) # 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions app.include_router(dashboard_sessions.router) - # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons - app.include_router(task_buttons.router) - - # Admin exact-match question/answer short-circuit at /api/fixed-questions - app.include_router(fixed_questions.router) + # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons + app.include_router(task_buttons.router) + + # Admin exact-match question/answer short-circuit at /api/fixed-questions + app.include_router(fixed_questions.router) # Deep-research report structures (工作流配置 / 报告结构) at /api/report-structures app.include_router(report_structures.router) diff --git a/offline-backend-20260512/backend/app/gateway/routers/sentiment_agent.py b/offline-backend-20260512/backend/app/gateway/routers/sentiment_agent.py new file mode 100644 index 00000000..92c8e470 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/sentiment_agent.py @@ -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", + }, + ) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 31606d1a..972804ab 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -573,6 +573,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 假数据 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 2d9285c1..b7c2a90c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -11,22 +11,23 @@ from pydantic import BaseModel, ConfigDict, Field from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict from deerflow.config.browser_context_config import BrowserContextConfig -from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict -from deerflow.config.concurrency_config import ConcurrencyConfig -from deerflow.config.database_config import DatabaseConfig +from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict +from deerflow.config.concurrency_config import ConcurrencyConfig +from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.gateway_config import GatewayConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict -from deerflow.config.knowledge_config import KnowledgeConfig -from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig -from deerflow.config.llmwiki_config import LlmWikiConfig -from deerflow.config.log_system_config import LogSystemConfig -from deerflow.config.login_config import AuthLoginConfig +from deerflow.config.knowledge_config import KnowledgeConfig +from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig +from deerflow.config.llmwiki_config import LlmWikiConfig +from deerflow.config.log_system_config import LogSystemConfig +from deerflow.config.login_config import AuthLoginConfig from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict 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 @@ -43,8 +44,8 @@ from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_searc from deerflow.config.vision_config import VisionConfig from deerflow.config.y_log_config import YLogConfig -load_dotenv() -load_dotenv(Path(__file__).resolve().parents[4] / ".env.local", override=True) +load_dotenv() +load_dotenv(Path(__file__).resolve().parents[4] / ".env.local", override=True) logger = logging.getLogger(__name__) @@ -117,8 +118,8 @@ class AppConfig(BaseModel): models: list[ModelConfig] = Field(default_factory=list, description="Available models") vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration") auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)") - log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu") - llmwiki: LlmWikiConfig = Field(default_factory=LlmWikiConfig, description="Conditional legacy/WeKnora LLMWiki integration") + log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu") + llmwiki: LlmWikiConfig = Field(default_factory=LlmWikiConfig, description="Conditional legacy/WeKnora LLMWiki integration") sandbox: SandboxConfig = Field(description="Sandbox configuration") tools: list[ToolConfig] = Field(default_factory=list, description="Available tools") tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups") @@ -141,13 +142,17 @@ class AppConfig(BaseModel): gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") - stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") - concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination") - browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") + stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") + concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination") + browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration") 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: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/sentiment_agent_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/sentiment_agent_config.py new file mode 100644 index 00000000..af775a64 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/sentiment_agent_config.py @@ -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="上游连接与读取超时(秒);流式问答可能持续数分钟", + ) diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 0a69220d..1fd87a16 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -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 From c6fcf51e0cde956950603741ee8105b1e6eb3abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 26 Aug 2026 09:41:53 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + .../workspace/agents/agent-card.tsx | 16 +- .../workspace/agents/agent-gallery.tsx | 37 +- .../workspace/agents/agent-guide.tsx | 332 ++++++++++++++++++ .../workspace/appearance-controls.tsx | 2 + .../workspace/compact-mode-guide.tsx | 220 ++++++++++++ .../components/workspace/recent-chat-list.tsx | 5 +- .../workspace/workspace-nav-chat-list.tsx | 5 +- .../workspace/workspace-nav-menu.tsx | 27 +- frontend-web/src/pages/AgentChatPage.tsx | 81 ++++- frontend-web/src/pages/ChatPage.tsx | 24 +- frontend-web/src/pages/NewAgentPage.tsx | 111 +++--- 12 files changed, 785 insertions(+), 77 deletions(-) create mode 100644 frontend-web/src/components/workspace/agents/agent-guide.tsx create mode 100644 frontend-web/src/components/workspace/compact-mode-guide.tsx diff --git a/AGENTS.md b/AGENTS.md index c66992d5..d08d33a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 +- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):7 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。 +- **智能体使用引导 (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/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 006f2811..c1bea33c 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -835,11 +835,14 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar ); const cardLayout = ( - +
-
+
@@ -893,8 +896,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar ); const listLayout = ( -
-
+
+
@@ -980,7 +986,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-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/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/compact-mode-guide.tsx b/frontend-web/src/components/workspace/compact-mode-guide.tsx new file mode 100644 index 00000000..19ff8fad --- /dev/null +++ b/frontend-web/src/components/workspace/compact-mode-guide.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Guide, type GuideStep } from "tdesign-react"; + +/** + * 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的 + * Guide 组件(https://tdesign.tencent.com/react/components/guide)。 + * + * 触发方式: + * - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写 + * localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。 + * - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过 + * COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到 + * /chats/new,由本组件在挂载后消费。 + * + * 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个 + * 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的 + * 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。 + */ + +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"]', + promptInput: '[data-compact-guide="prompt-input"]', +} as const; + +// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置, +// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。 +const START_DELAY_MS = 800; + +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; + mode: "popup" | "dialog"; + placement: NonNullable; + 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.promptInput, + mode: "popup", + placement: "top", + title: "开始提问", + body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。", + }, +]; + +function buildSteps(): GuideStep[] { + if (!isBrowser()) return []; + return STEP_SPECS.filter( + (spec) => !spec.selector || document.querySelector(spec.selector), + ).map((spec) => ({ + element: spec.selector ?? (() => document.body), + mode: spec.mode, + placement: spec.placement, + title: spec.title, + body: spec.body, + })); +} + +/** + * 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局 + * chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态, + * 满足「首次进入」条件时自动演示一遍。 + */ +export function CompactModeGuide({ + eligible, + autoStart = false, +}: { + eligible: boolean; + autoStart?: boolean; +}) { + const [current, setCurrent] = useState(-1); + const stepsRef = useRef([]); + + const start = useCallback(() => { + const steps = buildSteps(); + if (steps.length === 0) return; + stepsRef.current = steps; + setCurrent(0); + }, []); + + // 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」 + // 只由下方 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]); + + // 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或 + // 离开不记忆,下次进入简洁模式问答页还会再演示。 + const finish = useCallback(() => { + markCompactGuideDone(); + setCurrent(-1); + }, []); + + if (!eligible) return null; + + return ( + + ); +} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 3474188c..f0bbb213 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -206,7 +206,10 @@ export function RecentChatList({ limit != null ? visibleThreads.slice(0, limit) : visibleThreads; return ( <> - + - + {(showCollapseTrigger || newChatItem) && ( diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 05e15990..8e4ac50d 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -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} /> - + {mounted ? ( @@ -155,6 +176,10 @@ export function WorkspaceNavMenu() { )} + + + 界面使用引导 + { setSettingsDefaultSection("appearance"); diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 4487813a..87f07992 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -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. */}
{/* 最左:智能体列表栏(仅独立整页问答开启)。顶部下拉切换 我的/广场。 */} - {showAgentList && } + {showAgentList && ( +
+ +
+ )}
{/* Main conversation column. `relative` hosts the floating @@ -598,6 +614,20 @@ export default function AgentChatPage({
+ {/* 智能体使用引导(chat 阶段)手动重放入口。 */} + {agentGuideEligible && ( + + + + )} {/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */} {isTemplateBuilder && (
@@ -688,13 +718,17 @@ export default function AgentChatPage({ className="mb-7" />
- handleSubmit({ text, files: [] })} - /> -
+
+ handleSubmit({ text, files: [] })} + /> +
+
-
+
{/* Right: agent detail panel — always the rightmost column. */} - handleSubmit({ text, files: [] })} - defaultCollapsed={taskCtxOn} - showRecentChats={!taskCtxOn} - compactAppearanceActions={compactAppearanceActions} - /> +
+ handleSubmit({ text, files: [] })} + defaultCollapsed={taskCtxOn} + showRecentChats={!taskCtxOn} + compactAppearanceActions={compactAppearanceActions} + /> +
+ {/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */} {isTemplateBuilder && ( diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 260d62fd..a1cebce1 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -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 ( @@ -801,7 +814,10 @@ export default function ChatPage() { /> ))} {mounted ? ( -
+
)} +
diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index f5547f2f..c18a1165 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -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,16 +413,28 @@ export default function NewAgentPage() {

创建智能体

- {agent ? ( +
- ) : null} + {agent ? ( + + ) : null} +
-
+
当前对话模型 @@ -525,32 +546,35 @@ export default function NewAgentPage() {

-
- - { - setNameInput(event.currentTarget.value); - setFormError(""); - }} - className={cn(formError && "border-destructive")} - /> +
+
+ + { + setNameInput(event.currentTarget.value); + setFormError(""); + }} + className={cn(formError && "border-destructive")} + /> +
+ +
+ +