diff --git a/.gitignore b/.gitignore index c7d89ebd..aee22e47 100644 --- a/.gitignore +++ b/.gitignore @@ -105,5 +105,11 @@ docs/backups # uv lockfile (not tracked per project preference) offline-backend-20260512/backend/uv.lock +# Local-only scratch directory (not committed) +0622/ + +# Python bytecode cache +__pycache__/ + # misc .DS_Store \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 73a9b84b..a1cdf6cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,12 +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. +- **技能地址管理 (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). **`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 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`. +- **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). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). 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, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; 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 3837c075..b78f859a 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true VITE_MOCK_COP_TASK_DETAIL=true # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 -VITE_MOCK_SQ_REPORT=true +VITE_MOCK_SQ_REPORT=false # 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。 VITE_MOCK_COP_TASK_DETAIL=true diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index c28771b0..631d34a9 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -77,6 +77,7 @@ import { import { getAgent } from "@/core/agents/api"; import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { useI18n } from "@/core/i18n/hooks"; import { useSkills } from "@/core/skills/hooks"; import { formatDateTime } from "@/core/utils/datetime"; @@ -250,6 +251,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; + // 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。 + const live = useLiveAccount(); const isAdmin = storedAuth?.system_role === "admin"; const isBuiltin = agent.builtin; const isOwned = @@ -424,18 +427,38 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar setPublishPickerOpen(true); return; } + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, request: { published: !agent.published }, }); - toast.success(agent.published ? "已取消发布" : "已发布"); + toast.success( + agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`, + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } } + // 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的 + // 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。 + function warnIfAccountSwitched(): boolean { + if (!live.switched) { + return true; + } + return window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`, + ); + } + async function handlePublishToSquare(squareId: string) { + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -443,7 +466,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar }); const sqName = publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场"; - toast.success(`已发布到「${sqName}」`); + toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`); setPublishPickerOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -1117,6 +1140,23 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar loading={updateAgent.isPending} onConfirm={(squareId) => void handlePublishToSquare(squareId)} description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。" + accountNotice={ +
+

+ 发布操作账号: + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「 + {live.displayName}」。如需以「{live.mountDisplayName} + 」操作,请刷新页面确认。 +

+ )} +
+ } /> ); diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index a87be6ba..65b504f0 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -70,6 +70,8 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + bootstrapResumePath, + isBootstrapThread, isThreadFavorite, isThreadPinned, sortThreadsPinnedFirst, @@ -338,14 +340,14 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { const [renameValue, setRenameValue] = useState(""); const [shareThreadId, setShareThreadId] = useState(null); - // 排除调度/引导/会商类会话,与首页一致;置顶优先。 + // 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示 + // (带机器人图标、点击回创建页);置顶优先。 const visibleThreads = useMemo( () => sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ), @@ -450,7 +452,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { ) : ( filteredThreads.map((thread) => { - const href = pathOf(thread); + const isBoot = isBootstrapThread(thread); + const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const isActive = href === pathname; const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); @@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} 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 0d6bc0b1..e36087c8 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -43,6 +43,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; +import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet"; @@ -59,6 +60,7 @@ import { type UpdateAgentRequest, } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { useLiveAccount } from "@/core/auth/use-live-account"; import { isAgentSkillCandidate } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { usePublishSquares } from "@/core/system-settings/hooks"; @@ -135,6 +137,7 @@ export function AgentDetailPanel({ onAskQuestion, disabled, defaultCollapsed, + showRecentChats, }: { agent: Agent | null | undefined; agentName: string; @@ -142,6 +145,8 @@ export function AgentDetailPanel({ disabled?: boolean; /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ defaultCollapsed?: boolean; + /** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */ + showRecentChats?: boolean; }) { const router = useRouter(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -159,6 +164,8 @@ export function AgentDetailPanel({ const [deleteOpen, setDeleteOpen] = useState(false); const [publishPickerOpen, setPublishPickerOpen] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); + // 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。 + const live = useLiveAccount(); const updateAgent = useUpdateAgent(); const deleteAgent = useDeleteAgent(); @@ -343,19 +350,39 @@ export function AgentDetailPanel({ setPublishPickerOpen(true); return; } + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, request: { published: !agent.published }, }); - toast.success(agent.published ? "已下架" : "已发布"); + toast.success( + agent.published ? "已下架" : `已发布(操作账号:${live.displayName})`, + ); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } } + // 发布以实时 token 执行。会话中途账号被切换(常见于另一标签页登录了别的账号)时 + // 先弹确认,避免误以本人之外的账号执行发布。返回 false = 用户取消。 + function warnIfAccountSwitched(): boolean { + if (!live.switched) { + return true; + } + return window.confirm( + `当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` + + `本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`, + ); + } + async function handlePublishToSquare(squareId: string) { if (!agent) return; + if (!warnIfAccountSwitched()) { + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -363,7 +390,7 @@ export function AgentDetailPanel({ }); const sqName = publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场"; - toast.success(`已发布到「${sqName}」`); + toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`); setPublishPickerOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -791,6 +818,9 @@ export function AgentDetailPanel({ onViewDetail={setDetailSkillName} /> + + {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} + {showRecentChats && !editing && } ); @@ -921,6 +951,23 @@ export function AgentDetailPanel({ loading={updateAgent.isPending} onConfirm={(squareId) => void handlePublishToSquare(squareId)} description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。" + accountNotice={ +
+

+ 发布操作账号: + + 「{live.displayName}」 + +

+ {live.switched && ( +

+ ⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「 + {live.displayName}」。如需以「{live.mountDisplayName} + 」操作,请刷新页面确认。 +

+ )} +
+ } /> ); diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx new file mode 100644 index 00000000..de416639 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { MessageSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { useThreadsPage } from "@/core/threads/hooks"; +import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const RECENT_LIMIT = 8; + +/** + * 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。 + * 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。 + */ +export function AgentRecentChats({ agentId }: { agentId: string }) { + const pathname = usePathname(); + const { data } = useThreadsPage({ + page: 1, + pageSize: RECENT_LIMIT, + metadata: { agent_id: agentId }, + }); + // 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 + const threads = (data?.threads ?? []).filter( + (th) => + th.metadata?.thread_type !== "bootstrap" && + th.metadata?.thread_type !== "scheduler" && + th.metadata?.thread_type !== "roundtable", + ); + + return ( +
+
+

+ + 最近对话 +

+ + {threads.length} 个 + +
+ {threads.length === 0 ? ( +

暂无对话

+ ) : ( +
+ {threads.map((thread) => { + const href = pathOfThread(thread); + const isActive = href === pathname; + return ( + + + + {desensitize(titleOfThread(thread, "新对话"))} + + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx new file mode 100644 index 00000000..02efa7c8 --- /dev/null +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; +import { useLayoutMode } from "@/core/system-settings/hooks"; +import { cn } from "@/lib/utils"; + +/** + * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, + * 通过 `tone` 适配不同底色: + * - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。 + * - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。 + */ +export type AppearanceControlTone = "banner-dark" | "banner-light" | "content"; + +// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark) +export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [ + { id: "light", label: "红色", color: "#cf1322" }, + { id: "lightblue", label: "蓝色", color: "#0062d9" }, + { id: "dark", label: "深蓝色", color: "#002466" }, +]; + +const triggerToneClass = (tone: AppearanceControlTone) => + tone === "banner-dark" + ? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400" + : tone === "banner-light" + ? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]" + : "text-muted-foreground hover:bg-muted hover:text-foreground"; + +export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { + const { theme, setTheme } = useTheme(); + const [open, setOpen] = useState(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + closeTimer.current = setTimeout(() => setOpen(false), 120); + }; + + useEffect(() => () => cancelClose(), []); + + return ( +
{ + cancelClose(); + setOpen(true); + }} + onMouseLeave={scheduleClose} + > + + {open ? ( +
+
+
+ {THEME_SWATCHES.map((swatch) => { + const active = theme === swatch.id; + return ( +
+
+ ) : null} +
+ ); +} + +const LAYOUT_OPTIONS = [ + { + id: "full" as const, + label: "全量模式", + description: "展示侧边栏与顶栏导航", + icon: PanelLeftIcon, + }, + { + id: "compact" as const, + label: "简洁模式", + description: "隐藏侧边栏与顶栏,获得更大工作区", + icon: LayoutIcon, + }, +]; + +/** + * 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉: + * - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。 + * - 全量模式 Header 右上角。 + * 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面 + * 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。 + */ +export function LayoutModeSwitcher({ + tone, + fallback = "compact", +}: { + tone: AppearanceControlTone; + fallback?: "full" | "compact"; +}) { + const [mode, setLayoutMode] = useLayoutMode(); + const current = mode ?? fallback; + const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon; + + return ( + + + + + + 界面模式 + + {LAYOUT_OPTIONS.map((option) => { + const Icon = option.icon; + const active = current === option.id; + return ( + setLayoutMode(option.id)} + className="gap-2" + > + +
+ {option.label} + + {option.description} + +
+ {active && } +
+ ); + })} +
+
+ ); +} diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index d9062d86..b06e4123 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -75,8 +75,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -213,11 +215,12 @@ export function GeneralQaSidebar() { const visibleThreads = useMemo( () => + // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ), @@ -249,9 +252,9 @@ export function GeneralQaSidebar() { let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -417,7 +420,9 @@ export function GeneralQaSidebar() {
) : ( filteredThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -426,7 +431,7 @@ export function GeneralQaSidebar() { className="group/item relative flex items-center" > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx b/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx index 65aa7a51..fd6fecc2 100644 --- a/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx +++ b/frontend-web/src/components/workspace/publish-square-picker-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { CheckIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +26,8 @@ interface PublishSquarePickerDialogProps { loading?: boolean; title?: string; description?: string; + /** 可选提示节点,渲染在描述下方 —— 用于显示「本次发布将以哪个账号进行」及账号切换告警。 */ + accountNotice?: ReactNode; } /** A "publish to which square (发布广场)?" chooser. Squares are rendered as a @@ -39,6 +41,7 @@ export function PublishSquarePickerDialog({ loading, title = "选择发布广场", description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。", + accountNotice, }: PublishSquarePickerDialogProps) { const [selected, setSelected] = useState( defaultSquareId || squares[0]?.id || "", @@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({ {title} {description} + {accountNotice}
{squares.map((sq) => { const active = selected === sq.id; diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 702bcaa7..b996d0a3 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -1,6 +1,7 @@ "use client"; import { + Bot, ChevronDown, ChevronRight, Download, @@ -64,8 +65,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -130,9 +133,9 @@ export function RecentChatList({ }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -189,11 +192,12 @@ export function RecentChatList({ [t], ); + // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 const visibleThreads = sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ); @@ -239,7 +243,9 @@ export function RecentChatList({
{shownThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -261,17 +267,29 @@ export function RecentChatList({ > - {compact && ( - + ) : ( + compact && ( + + ) )} {pinned && ( diff --git a/frontend-web/src/components/workspace/recent-roundtable-list.tsx b/frontend-web/src/components/workspace/recent-roundtable-list.tsx index c1af707f..67482aab 100644 --- a/frontend-web/src/components/workspace/recent-roundtable-list.tsx +++ b/frontend-web/src/components/workspace/recent-roundtable-list.tsx @@ -71,9 +71,10 @@ function StatusBadge({ draft }: { draft: DraftMeta }) { if (draft.status === "error") { return 出错; } + // 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。 return ( - 第 {draft.furthestStep} 步 + {draft.chainTitle || "自由模式"} ); } diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 145259b4..389c4fe6 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -18,6 +18,7 @@ import { requestCrudService } from "@/service/request-axios-service"; import { readCopTaskDetail } from "@/core/auth/task-deeplink"; +import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; /** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */ export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson"; @@ -221,3 +222,43 @@ export async function fetchDashboardInputJson( if (resolved) persistDashboardInputJson(resolved); return resolved; } + +/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */ + +/** + * 「3q(研判情报报告)是否完成」状态: + * - ``complete`` = selectSqReport 数组里有有效内容; + * - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成); + * - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。 + */ +export type SqReportStatus = "complete" | "incomplete" | "unknown"; + +/** + * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` + * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 + * - 有可用内容 → ``complete``(正常); + * - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx); + * - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。 + * + * **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时): + * - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求** + * (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]); + * - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。 + */ +export async function fetchSqReportStatus(taskId: string): Promise { + if (!taskId) return "unknown"; + const cfg = await fetchTaskDeeplinkConfig(); + // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 + if (cfg.test) { + return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete"; + } + // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。 + const url = sqReportUrl(); + if (!url) return "unknown"; + try { + const body = await requestCrudService.get(url, { taskId }); + return extractSqReport(body, taskId) ? "complete" : "incomplete"; + } catch { + return "unknown"; + } +} diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index 86d5b716..be9b18ac 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig { /** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */ detail3qUrl: string; /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}; - * actionDetail = 原始 {state,msg,data:{taskId,...}}。 */ + * actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]} + * (rwfx 3q 预检用,test 为真时生效)。 */ mock: { purposeDetail?: unknown; delete?: { message?: string; success?: boolean }; actionDetail?: unknown; + sqReport?: unknown; }; } @@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig { ? (mock.delete as { message?: string; success?: boolean }) : undefined, actionDetail: mock.action_detail, + sqReport: mock.sq_report, }, }; } diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 60076bd6..0208f579 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -233,12 +233,16 @@ export interface TaskDeeplinkTarget { state?: Record; } -/** 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。 */ -function buildOpeningText(detail: CopTaskDetail, taskId: string): string { +/** + * 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。 + * xdfx(行动分析)额外附上「行动id(action_id)」——传入 actionId 时追加该行。 + */ +function buildOpeningText(detail: CopTaskDetail, taskId: string, actionId?: string): string { return [ detail.taskName ? `任务名称:${detail.taskName}` : "", detail.taskContent ? `任务内容:${detail.taskContent}` : "", taskId ? `任务id(task_id)为${taskId}` : "", + actionId ? `行动id(action_id)为${actionId}` : "", ] .filter(Boolean) .join("\n"); @@ -312,11 +316,15 @@ export async function resolveTaskDeeplinkTarget( // 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定 // 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表, // 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。 + // xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加 action_id。 + const isXdfx = parsed.businessCode === XDFX_BUSINESS_CODE; + const actionId = rawId || taskId; persistTaskContext({ taskId, agentId, - openingText: buildOpeningText(detail, taskId), - actionId: rawId || taskId, + openingText: buildOpeningText(detail, taskId, isXdfx ? actionId : undefined), + actionId, + partitionByAction: isXdfx, }); return { path: `${taskBase}/agent/${agentId}/chats` }; } diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts index 5fe3c5f2..375e3c51 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -41,6 +41,12 @@ export interface TaskContext { openingText: string; /** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */ actionId?: string; + /** + * xdfx(行动分析)专属:聊天记录按「行动id」(actionId) 而非「任务id」划分。 + * 为 true 时,新建对话额外打 `metadata.actionId` 标签、对话列表按 actionId 过滤 + * (仍保留 taskId 标签以维持任务深链对话「全局共享、不分用户」的后端语义)。 + */ + partitionByAction?: boolean; } const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext"; @@ -311,12 +317,29 @@ export function readTaskContext(): TaskContext | null { agentId: String(parsed.agentId), openingText: typeof parsed.openingText === "string" ? parsed.openingText : "", actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined, + partitionByAction: parsed.partitionByAction === true, }; } catch { return null; } } +/** + * 任务深链对话的 thread metadata 标签(既用于「新建对话」打标签,也用于对话列表过滤)。 + * - 普通业务(rwfx/3qfx):`{ taskId }` —— 按任务划分(1:N)。 + * - xdfx(partitionByAction):`{ taskId, actionId }` —— 额外按「行动id」划分聊天记录; + * 仍带 taskId 以保留后端「任务深链对话全局共享」的语义(后端据 metadata.taskId 跳过分用户过滤)。 + * 无有效 taskId 时返回 undefined(调用方据此不打标签)。 + */ +export function taskThreadMetadata( + ctx: TaskContext | null | undefined, +): Record | undefined { + if (!ctx?.taskId) return undefined; + return ctx.partitionByAction && ctx.actionId + ? { taskId: ctx.taskId, actionId: ctx.actionId } + : { taskId: ctx.taskId }; +} + export function clearTaskContext(): void { try { sessionStorage.removeItem(TASK_CONTEXT_STORAGE_KEY); diff --git a/frontend-web/src/core/auth/use-live-account.ts b/frontend-web/src/core/auth/use-live-account.ts new file mode 100644 index 00000000..a9417694 --- /dev/null +++ b/frontend-web/src/core/auth/use-live-account.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; +import { isEmbedActive } from "@/core/embed/embed-session"; + +const AUTH_STORAGE_KEY = "deerflow.auth"; + +export interface LiveAccount { + /** 实时登录用户 id(随 storage/focus 刷新)。null = 未登录。 */ + userId: string | null; + /** 实时显示名(邮箱 @ 前缀,否则 user_id)。 */ + displayName: string; + /** 组件首次挂载时捕获的用户 id。 */ + mountUserId: string | null; + /** 组件首次挂载时捕获的显示名。 */ + mountDisplayName: string; + /** + * 当前登录账号与挂载时不一致 —— 即本视图加载后登录被切换过(通常是在另一个 + * 标签页登录了别的账号)。归属/发布类操作必须据此提醒,避免误归属。 + */ + switched: boolean; +} + +/** + * 跟踪「实时」登录账号,而不是挂载时缓存的值。 + * + * 鉴权 token 存在 localStorage(非嵌入态下被同浏览器所有标签页共享),所以在 + * 另一个标签页登录会**静默替换**本标签页 API 调用所用的 token。任何会决定归属的 + * 界面(创建/发布智能体)都必须显示请求实际将以哪个账号执行,并在会话中途被切换 + * 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。 + */ +export function useLiveAccount(): LiveAccount { + const mountRef = useRef<{ id: string | null; name: string } | null>(null); + if (mountRef.current === null) { + const auth = getStoredAuth(); + mountRef.current = { + id: auth?.user_id ?? null, + name: getAccountDisplayName(auth), + }; + } + const [live, setLive] = useState(() => mountRef.current!); + + useEffect(() => { + function refresh() { + const auth = getStoredAuth(); + const id = auth?.user_id ?? null; + const name = getAccountDisplayName(auth); + setLive((prev) => + prev.id === id && prev.name === name ? prev : { id, name }, + ); + } + // 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件 + // —— 只挂在正常态;focus/visibilitychange 兜底刷新两种形态都有效。 + function onStorage(e: StorageEvent) { + if (e.key === AUTH_STORAGE_KEY) { + refresh(); + } + } + if (!isEmbedActive()) { + window.addEventListener("storage", onStorage); + } + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + refresh(); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, []); + + const mount = mountRef.current!; + return { + userId: live.id, + displayName: live.name, + mountUserId: mount.id, + mountDisplayName: mount.name, + switched: live.id !== null && mount.id !== null && live.id !== mount.id, + }; +} diff --git a/frontend-web/src/core/threads/use-task-threads.ts b/frontend-web/src/core/threads/use-task-threads.ts index b9101dc4..f9a7cd19 100644 --- a/frontend-web/src/core/threads/use-task-threads.ts +++ b/frontend-web/src/core/threads/use-task-threads.ts @@ -8,6 +8,10 @@ * 同一个 taskId 可能挂在多条业务链上(rwfx / 3qfx / xdfx 共用任务 id),每条业务用各自的 * 智能体。传入 ``agentId`` 时额外按 ``metadata.agent_id`` 过滤(后端 metadata 过滤是 AND/子集 * 匹配),把不同业务的对话列表隔离开——否则三种业务的对话会混在一起。 + * + * xdfx(行动分析):聊天记录按「行动id」划分——传入 ``actionId`` 时额外按 ``metadata.actionId`` + * 过滤(同一任务下的不同行动各自一份对话列表);仍保留 taskId 过滤以命中后端「任务深链对话 + * 全局共享」的判定(后端据 metadata.taskId 跳过分用户过滤)。 */ import { useThreadsPage } from "@/core/threads/hooks"; @@ -16,13 +20,16 @@ import type { AgentThread } from "@/core/threads/types"; export function useTaskThreads( taskId: string | null | undefined, agentId?: string | null | undefined, + actionId?: string | null | undefined, ) { const enabled = !!taskId; // 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。 const metadata = enabled - ? agentId - ? { taskId, agent_id: agentId } - : { taskId } + ? { + taskId, + ...(actionId ? { actionId } : {}), + ...(agentId ? { agent_id: agentId } : {}), + } : { taskId: "__none__" }; const query = useThreadsPage({ page: 1, pageSize: 100, metadata }); const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : []; diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 7a3d8769..adc0ded4 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") { return fallback; } +/** + * 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type + * === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。 + */ +export function isBootstrapThread(thread: { + metadata?: Record | null; +}): boolean { + return thread.metadata?.thread_type === "bootstrap"; +} + +/** + * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 + * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, + * 不随当前 route base 变化。 + */ +export function bootstrapResumePath(threadId: string): string { + return `/page/workspace/agents/new/${threadId}`; +} + +/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ +export function listPathOfThread(thread: AgentThread): string { + return isBootstrapThread(thread) + ? bootstrapResumePath(thread.thread_id) + : pathOfThread(thread); +} + export function isThreadPinned(thread: AgentThread): boolean { return (thread.metadata as Record | null)?.is_pinned === true; } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index aa6a7d60..c89efb9f 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -192,7 +192,7 @@ export function AIWritingForm({