diff --git a/CLAUDE.md b/CLAUDE.md index 91f9c7b3..73a9b84b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,11 +82,13 @@ 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). - **敏感词管理 (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. +- **按钮管理 (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). **`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`. - **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/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) -- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **per-task draft isolation** — drafts save/list/restore by `task_id` (one taskId = many records, shared across users; backend `GET /api/roundtable-drafts/by-task/{id}` + `/latest`); the history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`. +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 ### Configuration diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 05435bf2..3837c075 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -7,6 +7,12 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。 VITE_MOCK_COP_TASK_DETAIL=true +# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 +VITE_MOCK_SQ_REPORT=true + +# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。 +VITE_MOCK_COP_TASK_DETAIL=true + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api diff --git a/frontend-web/.env.network b/frontend-web/.env.network index 471b6289..ae615eb8 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -7,6 +7,12 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。 VITE_MOCK_COP_TASK_DETAIL=false +# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 +VITE_MOCK_SQ_REPORT=false + +# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。 +VITE_MOCK_COP_TASK_DETAIL=false + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api diff --git a/frontend-web/.env.production b/frontend-web/.env.production index d1fc2180..a096e3b5 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -5,7 +5,10 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。 -VITE_MOCK_COP_TASK_DETAIL=true +VITE_MOCK_COP_TASK_DETAIL=false + +# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 +VITE_MOCK_SQ_REPORT=false # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 91ba650f..31dda6ed 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -7,6 +7,12 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。 VITE_MOCK_COP_TASK_DETAIL=false +# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 +VITE_MOCK_SQ_REPORT=false + +# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。 +VITE_MOCK_COP_TASK_DETAIL=false + # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 6c1db30f..2b9e6c9a 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { ThemeProvider as NextThemesProvider } from "next-themes"; @@ -17,6 +17,10 @@ import { } from "@/core/embed/params"; import { usePublicEmbedTheme } from "@/core/embed/background"; import { readWujieProps } from "@/core/embed/wujie"; +import { + resolveForcedEmbedTheme, + syncEmbedSessionFromSearch, +} from "@/core/embed/embed-session"; import { HashRouter, useLocation } from "react-router-dom"; @@ -41,7 +45,16 @@ function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); const publicEmbedTheme = usePublicEmbedTheme(); + // iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页 + // 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。 + useEffect(() => { + syncEmbedSessionFromSearch(location.search); + }, [location.search]); const forcedTheme = useMemo(() => { + // iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。 + // 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。 + const embedTheme = resolveForcedEmbedTheme(location.search); + if (embedTheme) return embedTheme; const routeTheme = resolveForcedThemeForPath(location.pathname); if (routeTheme) return routeTheme; if (location.pathname === "/embed/roundtable") { diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 33b79a6b..d01d6d6e 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -8,6 +8,7 @@ import { Folder, FolderOpen, ListTree, + MousePointerClick, Settings, ShieldAlert, User, @@ -213,6 +214,14 @@ function SecondaryNavSidebar() { 菜单管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/task-buttons")} + > + + 按钮管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} @@ -493,6 +502,12 @@ function LegacyAccordionSidebar({ 菜单管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/task-buttons")}> + + 按钮管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")}> diff --git a/frontend-web/src/components/workspace/admin/position-management-page.tsx b/frontend-web/src/components/workspace/admin/position-management-page.tsx index a747510c..dd80a765 100644 --- a/frontend-web/src/components/workspace/admin/position-management-page.tsx +++ b/frontend-web/src/components/workspace/admin/position-management-page.tsx @@ -401,7 +401,7 @@ export function PositionManagementPage() { }; return ( -
+

岗位管理

diff --git a/frontend-web/src/components/workspace/admin/user-management-page.tsx b/frontend-web/src/components/workspace/admin/user-management-page.tsx index 0c61d323..5847263c 100644 --- a/frontend-web/src/components/workspace/admin/user-management-page.tsx +++ b/frontend-web/src/components/workspace/admin/user-management-page.tsx @@ -12,7 +12,7 @@ export function UserManagementPage() { const { data: users, isLoading, error } = useAllUsers(true); return ( -
+
+
+
+

会商技能识别强化

+

+ 作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file + 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置里打开(任一即生效)。 +

+
+ +

公开发布

diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx new file mode 100644 index 00000000..a87be6ba --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -0,0 +1,620 @@ +"use client"; + +import { + Bot, + ChevronDown, + Download, + FileJson, + FileText, + Loader2, + MessageSquare, + MoreHorizontal, + Pencil, + Pin, + PinOff, + Search, + Share2, + Star, + Trash2, + X, +} from "lucide-react"; +import Link from "next/link"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { agentColor } from "@/components/workspace/agent-icon-color"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { useTheme } from "@/contexts/ThemeContext"; +import { useAgentsPaginated } from "@/core/agents"; +import { getAPIClient } from "@/core/api"; +import { useI18n } from "@/core/i18n/hooks"; +import { useRouteBase } from "@/core/route-base"; +import { usePublishSquares } from "@/core/system-settings/hooks"; +import { + exportThreadAsJSON, + exportThreadAsMarkdown, +} from "@/core/threads/export"; +import { + useDeleteThread, + useRenameThread, + useThreads, + useToggleThreadFavorite, + useToggleThreadPin, +} from "@/core/threads/hooks"; +import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { + isThreadFavorite, + isThreadPinned, + sortThreadsPinnedFirst, + titleOfThread, + usePathOfThread, +} from "@/core/threads/utils"; +import { env } from "@/env"; +import { desensitize } from "@/lib/desensitize"; +import { isIMEComposing } from "@/lib/ime"; +import { cn } from "@/lib/utils"; + +/** 「我的」作用域的下拉选项值;其余选项值为各发布广场(publish square)的 id。 */ +const MINE_SCOPE = "mine"; + +// 列表一次拉取的条数。超出时底部出现「查看全部」入口,跳到完整的智能体管理页, +// 避免静默截断。侧边栏只做快速切换,不在此分页。 +const LIST_PAGE_SIZE = 50; + +// 「最近的对话」未搜索时最多展示条数(与首页「通用问答」左栏一致)。 +const MAX_RECENT_CHATS = 10; + +/** + * 智能体问答页左侧「智能体列表 + 最近对话」栏(参考首页通用问答左栏)。 + * + * 自上而下: + * 1. 顶部下拉:选「我的」(默认,= 我创建/收藏的)或任一「广场」(发布广场), + * 与「智能体管理」页主标签一致;下方为智能体名称模糊搜索框。 + * 2. 智能体列表:所选作用域下的智能体;点击进入该智能体的新对话。当前智能体高亮。 + * 3. 最近的对话:跨智能体的近期会话(参考首页),点击打开;支持重命名/置顶/收藏/ + * 分享/导出/删除。当前会话高亮。 + * + * 仅在独立的智能体问答整页展示;任务工作区 / zzJC·zzXD 模块 / 嵌入态不展示。 + */ +export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) { + const { t } = useI18n(); + const routeBase = useRouteBase(); + const { theme } = useTheme(); + const isDark = theme === "dark"; + + const { data: publishSquares } = usePublishSquares(); + // 每个发布广场对应一个下拉选项;后端至少返回一个内置默认广场。 + const squares = useMemo( + () => publishSquares?.squares ?? [], + [publishSquares?.squares], + ); + + // 作用域:MINE_SCOPE 或某个广场 id。默认「我的」。 + const [scope, setScope] = useState(MINE_SCOPE); + + // 模糊搜索智能体名称:防抖 300ms 后走服务端 search(后端按原始名 / 脱敏名匹配), + // 在当前来源(我的 / 某广场)内过滤,能搜全量而非仅当前 50 条。 + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setSearch(searchInput.trim()), 300); + return () => clearTimeout(timer); + }, [searchInput]); + + // 「智能体」区可收起(持久化);收起后只留标题栏,下方「最近的对话」占满剩余空间。 + const [agentsCollapsed, setAgentsCollapsed] = useState( + () => + typeof window !== "undefined" && + localStorage.getItem("agent-chat-sidebar:agents-collapsed") === "true", + ); + const toggleAgents = () => + setAgentsCollapsed((v) => { + localStorage.setItem("agent-chat-sidebar:agents-collapsed", String(!v)); + return !v; + }); + + // 若所选广场被管理员删除,回退到「我的」,避免指向不存在的广场。 + useEffect(() => { + if ( + scope !== MINE_SCOPE && + squares.length > 0 && + !squares.some((sq) => sq.id === scope) + ) { + setScope(MINE_SCOPE); + } + }, [scope, squares]); + + const isMine = scope === MINE_SCOPE; + const { agents, total, isLoading, isFetching } = useAgentsPaginated({ + scope: isMine ? "mine" : "square", + sort: "time_desc", + page: 1, + pageSize: LIST_PAGE_SIZE, + square: isMine ? undefined : scope, + search: search || undefined, + }); + + return ( + // 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。 + + ); +} + +/** + * 「最近的对话」面板(参考首页 GeneralQaSidebar 的「最近的对话」区块)。 + * 跨智能体的近期会话,点击打开;支持重命名/置顶/收藏/分享/导出/删除。 + */ +function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { + const { t } = useI18n(); + const pathname = usePathname(); + const router = useRouter(); + const pathOf = usePathOfThread(); + + const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); + const { data: threads = [] } = useThreads(); + const { mutate: deleteThread } = useDeleteThread(); + const { mutate: renameThread } = useRenameThread(); + const { toggle: togglePin } = useToggleThreadPin(); + const { toggle: toggleFavorite } = useToggleThreadFavorite(); + + const [query, setQuery] = useState(""); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [renameThreadId, setRenameThreadId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [shareThreadId, setShareThreadId] = useState(null); + + // 排除调度/引导/会商类会话,与首页一致;置顶优先。 + const visibleThreads = useMemo( + () => + sortThreadsPinnedFirst( + threads.filter( + (thread) => + thread.metadata?.thread_type !== "scheduler" && + thread.metadata?.thread_type !== "bootstrap" && + thread.metadata?.thread_type !== "roundtable", + ), + ), + [threads], + ); + + const filteredThreads = useMemo(() => { + const q = query.trim().toLowerCase(); + const list = q + ? visibleThreads.filter((thread) => + titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q), + ) + : visibleThreads; + return q ? list : list.slice(0, MAX_RECENT_CHATS); + }, [visibleThreads, query, t.pages.newChat]); + + const handleDelete = useCallback( + (threadId: string) => { + deleteThread({ threadId }); + if (threadId === threadIdFromPath) { + const threadIndex = threads.findIndex((th) => th.thread_id === threadId); + let nextThreadPath = pathOf("new", { agent_id: activeAgentId }); + if (threadIndex > -1) { + if (threads[threadIndex + 1]) { + nextThreadPath = pathOf(threads[threadIndex + 1]!); + } else if (threads[threadIndex - 1]) { + nextThreadPath = pathOf(threads[threadIndex - 1]!); + } + } + void router.push(nextThreadPath); + } + }, + [activeAgentId, deleteThread, pathOf, router, threadIdFromPath, threads], + ); + + const handleRenameClick = useCallback( + (threadId: string, currentTitle: string) => { + setRenameThreadId(threadId); + setRenameValue(currentTitle); + setRenameDialogOpen(true); + }, + [], + ); + + const handleRenameSubmit = useCallback(() => { + if (renameThreadId && renameValue.trim()) { + renameThread({ threadId: renameThreadId, title: renameValue.trim() }); + setRenameDialogOpen(false); + setRenameThreadId(null); + setRenameValue(""); + } + }, [renameThread, renameThreadId, renameValue]); + + const handleExport = useCallback( + async (thread: AgentThread, format: "markdown" | "json") => { + try { + const apiClient = getAPIClient(); + const state = await apiClient.threads.getState( + thread.thread_id, + ); + const messages = state.values?.messages ?? []; + if (messages.length === 0) { + toast.error(t.conversation.noMessages); + return; + } + if (format === "markdown") exportThreadAsMarkdown(thread, messages); + else exportThreadAsJSON(thread, messages); + toast.success(t.common.exportSuccess); + } catch { + toast.error("Failed to export conversation"); + } + }, + [t], + ); + + const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; + + return ( +
+

+ {t.sidebar.recentChats} + + {visibleThreads.length} + +

+ + {/* 历史会话搜索框 */} +
+ + setQuery(e.target.value)} + placeholder="搜索历史会话…" + className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]" + /> +
+ +
+ {filteredThreads.length === 0 ? ( +
+ {query ? "没有匹配的会话" : t.chats.noChats} +
+ ) : ( + filteredThreads.map((thread) => { + const href = pathOf(thread); + const isActive = href === pathname; + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); + return ( +
+ + + {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + + {isActive && ( + + )} + + {!isStatic && ( + + + + + + + handleRenameClick( + thread.thread_id, + titleOfThread(thread, t.pages.newChat), + ) + } + > + + {t.common.rename} + + togglePin(thread.thread_id, !pinned)} + > + {pinned ? ( + + ) : ( + + )} + {pinned ? t.common.unpin : t.common.pin} + + + toggleFavorite(thread.thread_id, !favorited) + } + > + + + {favorited ? t.common.unfavorite : t.common.favorite} + + + setShareThreadId(thread.thread_id)} + > + + {t.common.share} + + + + + {t.common.export} + + + handleExport(thread, "markdown")} + > + + {t.common.exportAsMarkdown} + + handleExport(thread, "json")} + > + + {t.common.exportAsJSON} + + + + + handleDelete(thread.thread_id)} + > + + {t.common.delete} + + + + )} +
+ ); + }) + )} +
+ + {/* 重命名弹窗 */} + + + + {t.common.rename} + +
+ setRenameValue(e.target.value)} + placeholder={t.common.rename} + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + handleRenameSubmit(); + } + }} + /> +
+ + + + +
+
+ + {/* 分享弹窗 */} + { + if (!o) setShareThreadId(null); + }} + /> +
+ ); +} 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 f9a3292e..0d6bc0b1 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -76,6 +76,8 @@ interface AgentDraft { published: boolean; featured: boolean; templateName: string; + /** 会商席位「技能识别强化」开关。 */ + seatSkillDirective: boolean; } function draftFromAgent(agent: Agent): AgentDraft { @@ -88,6 +90,7 @@ function draftFromAgent(agent: Agent): AgentDraft { published: agent.published, featured: agent.featured_order != null, templateName: agent.template_name ?? "", + seatSkillDirective: agent.seat_skill_directive ?? false, }; } @@ -131,14 +134,17 @@ export function AgentDetailPanel({ agentName, onAskQuestion, disabled, + defaultCollapsed, }: { agent: Agent | null | undefined; agentName: string; onAskQuestion: (text: string) => void; disabled?: boolean; + /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ + defaultCollapsed?: boolean; }) { const router = useRouter(); - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); const [mobileOpen, setMobileOpen] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); @@ -292,6 +298,8 @@ export function AgentDetailPanel({ if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul; if (draft.templateName.trim() !== (agent.template_name ?? "")) request.template_name = draft.templateName.trim(); + if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false)) + request.seat_skill_directive = draft.seatSkillDirective; if (nextPublished !== agent.published) request.published = nextPublished; if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? [])) request.skills = draft.skills; @@ -558,6 +566,21 @@ export function AgentDetailPanel({ 配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。

+
+
+

会商技能识别强化

+

+ 作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file + 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置中打开(任一即生效)。 +

+
+ + setDraft({ ...draft, seatSkillDirective: checked }) + } + /> +

公开发布

diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index fa1a21dc..335346cd 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -364,7 +364,7 @@ export function AgentGallery() { }; return ( -
+
{/* Page header */}
@@ -392,7 +392,7 @@ export function AgentGallery() { setSort("time_desc"); setPublisher(null); }} - className="flex flex-1 flex-col overflow-hidden" + className="flex min-h-0 flex-1 flex-col overflow-hidden" >
diff --git a/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx index a30f74af..54a8902f 100644 --- a/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx @@ -170,8 +170,10 @@ export function AgentModuleSidebar({ }); return ( - // 背景按需求固定白色(不走 sidebar token),与全量版 GeneralQaSidebar 的卡片视觉看齐。 -