From 16afa9bcbbed0b0c5a9f2e3bbb46701417a1f597 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 18 Jun 2026 15:41:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=8F=AF=E9=85=8D=E7=BD=AE=E8=B7=B3=E8=BD=AC=E6=8C=89?= =?UTF-8?q?=E9=92=AE(=E6=8C=89=E9=92=AE=E7=AE=A1=E7=90=86)=20+=20=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=BF=AB=E6=8D=B7=E8=B7=B3=E8=BD=AC=E6=A0=8F=20+=20?= =?UTF-8?q?=E7=9B=AE=E7=9A=84=E6=A0=91=E6=94=B6=E8=B5=B7/=E7=A9=BA?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=97=AE=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「按钮管理」admin 页(/admin/task-buttons):按业务(rwfx/3qfx/xdfx)配置跳转按钮, 支持改名称/类型(外链·切换业务·目的树弹框)/目标/追加参数名(id·task_id)/任务id·行动id/启停/排序 - 跳转按钮从左侧栏移到工作区底部「快捷跳转」栏:红底白字、靠右、变大;rwfx 历史 4 按钮纳入配置 - 后端新增全局共享 store /api/task-buttons (table task_buttons,admin 整体替换) - xdfx 行动id/任务id:登录暂存原始行动id,按钮可选追加任一;id 参数名可自定义 - rwfx 清除记录移到「新建对话」下、去掉任务操作框;查询任务目的为空时自动发第一次问答 - 目的树面板默认收起;「弹框查看」抽成可复用 TaskPurposeDialog,可作为按钮配置到任意页面 - 文档(根/后端 CLAUDE.md) + 后端测试 tests/test_task_buttons.py Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +- frontend-web/src/components/page-sidebar.tsx | 15 + frontend-web/src/core/auth/task-buttons.ts | 246 ++++++++++++ frontend-web/src/core/auth/task-deeplink.ts | 14 +- frontend-web/src/core/auth/task-purpose.ts | 3 + frontend-web/src/pages/AgentChatPage.tsx | 9 +- frontend-web/src/pages/LoginPage.tsx | 2 +- frontend-web/src/pages/StrategyRoutes.tsx | 22 +- frontend-web/src/pages/TaskButtonsPage.tsx | 375 ++++++++++++++++++ .../strategy-components/api/task-buttons.ts | 114 ++++++ .../task-components/TaskFxWorkspace.tsx | 228 ++++++----- .../task-components/TaskPurposePanel.tsx | 111 ++++-- offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 4 + .../app/gateway/routers/task_buttons.py | 104 +++++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/task_buttons/__init__.py | 22 + .../deerflow/persistence/task_buttons/base.py | 30 ++ .../persistence/task_buttons/memory.py | 42 ++ .../persistence/task_buttons/model.py | 54 +++ .../deerflow/persistence/task_buttons/sql.py | 66 +++ 22 files changed, 1341 insertions(+), 130 deletions(-) create mode 100644 frontend-web/src/core/auth/task-buttons.ts create mode 100644 frontend-web/src/pages/TaskButtonsPage.tsx create mode 100644 frontend-web/src/strategy-components/api/task-buttons.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/task_buttons.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py diff --git a/CLAUDE.md b/CLAUDE.md index 8d90edd9..c844d39c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,11 +82,12 @@ 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 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) **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}` 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 + 编辑/下一步/清除记录 task actions + a「切换到三情分析」button (carries the same taskId into 3qfx via `resolveTaskDeeplinkTarget("3qfx", …)`); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite` (no task-actions/jump in the left panel); **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). The shared 目的树闸门 (`TaskPurposePanel`) carries a「3q详情」link beside「弹框查看」(`buildDetail3qUrl` → `detail_3q_url`+`?id=任务id`, opens new tab; toasts 未配置 when the URL is blank — it's TBD). `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). 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}` 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`. ### Configuration 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/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts new file mode 100644 index 00000000..e7f10d42 --- /dev/null +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -0,0 +1,246 @@ +/** + * 任务工作区(任务深链 rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)左侧栏的 + * **可配置跳转按钮**。 + * + * 数据源优先级:后端 ``/api/task-buttons``(全局共享、admin 整体替换)。**仅当整张表 + * 为空(全新部署、从未保存过)**时回退到代码种子默认(``defaultTaskButtons``,从实时 + * 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时 + * config 进种子,避免写死;一旦保存过,后端即唯一数据源。 + * + * 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/ + * ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。 + */ + +import { + appendQueryParam, + type TaskDeeplinkConfig, +} from "@/core/auth/task-deeplink-config"; +import { + readCopTaskDetail, + resolveTaskDeeplinkTarget, +} from "@/core/auth/task-deeplink"; + +/** 任务工作区业务码(与路由 taskBase 一一对应)。 */ +export type TaskBusiness = "rwfx" | "3qfx" | "xdfx"; + +/** 业务元数据:每个 = 一个任务深链页面。``goPath`` 为深链 ``?goPath=`` 值;``desc`` 给管理页说明。 */ +export const TASK_BUSINESSES: { + code: TaskBusiness; + /** 页面名(tab 主标题)。 */ + name: string; + /** 深链 goPath 值。 */ + goPath: string; + /** 页面说明。 */ + desc: string; +}[] = [ + { + code: "rwfx", + name: "任务分析", + goPath: "rwfx", + desc: "完整版任务工作区(带目的树闸门),通过 ?goPath=rwfx 进入。", + }, + { + code: "3qfx", + name: "三情分析", + goPath: "3qfx", + desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。", + }, + { + code: "xdfx", + name: "行动分析", + goPath: "xdfx", + desc: "精简版工作区,通过 ?goPath=xdfx 进入(深链 taskId 实为行动id)。", + }, +]; + +/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */ +export type TaskButtonLinkType = "url" | "business" | "purpose"; + +/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */ +export type TaskButtonIdKind = "task" | "action"; + +/** 一个可配置跳转按钮。 */ +export interface TaskButton { + id: string; + business: TaskBusiness; + label: string; + linkType: TaskButtonLinkType; + /** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */ + target: string; + /** ``url`` 类型是否追加 id 查询参数。 */ + appendTaskId: boolean; + /** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */ + idParam: string; + /** + * ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=`` + * 实为行动id,任务id 由 action 接口解析。其它业务两者相同。 + */ + idKind: TaskButtonIdKind; + enabled: boolean; + sortOrder: number; +} + +/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */ +export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [ + { code: "3qfx", label: "三情分析" }, + { code: "rwfx", label: "任务分析" }, + { code: "xdfx", label: "行动分析" }, +]; + +/** + * 代码种子默认按钮(仅在后端整张表为空时使用): + * - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析; + * - 3qfx / xdfx:互相切换到另外两个业务。 + * 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。 + */ +export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { + const sw = ( + id: string, + business: TaskBusiness, + label: string, + target: TaskBusiness, + sortOrder: number, + ): TaskButton => ({ + id, + business, + label, + linkType: "business", + target, + appendTaskId: false, + idParam: "id", + idKind: "task", + enabled: true, + sortOrder, + }); + // 目的树弹框按钮(无需 target)。 + const pz = (id: string, business: TaskBusiness, sortOrder: number): TaskButton => ({ + id, + business, + label: "弹框查看", + linkType: "purpose", + target: "", + appendTaskId: false, + idParam: "id", + idKind: "task", + enabled: true, + sortOrder, + }); + return [ + // rwfx(任务分析):外链 + 切换 + { + id: "rwfx-edit", + business: "rwfx", + label: "编辑", + linkType: "url", + target: cfg.treemapEditUrl, + appendTaskId: true, + idParam: "id", + idKind: "task", + enabled: true, + sortOrder: 0, + }, + { + id: "rwfx-next", + business: "rwfx", + label: "下一步", + linkType: "url", + target: cfg.treemapNextUrl, + appendTaskId: true, + idParam: "id", + idKind: "task", + enabled: true, + sortOrder: 1, + }, + { + id: "rwfx-detail3q", + business: "rwfx", + label: "3q详情", + linkType: "url", + target: cfg.detail3qUrl, + appendTaskId: true, + idParam: "id", + idKind: "task", + enabled: true, + sortOrder: 2, + }, + sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3), + sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4), + pz("rwfx-purpose", "rwfx", 5), + // 3qfx(三情分析):切换到任务/行动分析 + sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0), + sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1), + // xdfx(行动分析):切换到任务/三情分析 + 弹框查看 + sw("xdfx-switchrw", "xdfx", "切换到任务分析", "rwfx", 0), + sw("xdfx-switch3q", "xdfx", "切换到三情分析", "3qfx", 1), + pz("xdfx-purpose", "xdfx", 2), + ]; +} + +/** + * 实际生效的按钮列表:后端有任意一条记录时即以后端为准;整张表为空才回退种子默认。 + * (与 [[敏感词管理]] 的种子语义一致:空=从未配置→用种子;非空=已配置→后端说了算, + * 哪怕某业务被清空也不回退。) + */ +export function resolveTaskButtons( + stored: TaskButton[], + cfg: TaskDeeplinkConfig, +): TaskButton[] { + return stored.length > 0 ? stored : defaultTaskButtons(cfg); +} + +/** 取某业务下、启用的按钮,按 sortOrder 升序。 */ +export function buttonsForBusiness( + buttons: TaskButton[], + business: TaskBusiness, +): TaskButton[] { + return buttons + .filter((b) => b.business === business && b.enabled) + .sort((a, b) => a.sortOrder - b.sortOrder); +} + +export interface RunTaskButtonCtx { + /** 任务id(xdfx 为 action 接口解析后的真实任务id;其它业务即深链原始 id)。 */ + taskId: string; + /** 行动id(xdfx 深链原始 ?taskId=;其它业务通常等于 taskId)。 */ + actionId?: string; + navigate: (path: string, opts?: { state?: Record }) => void; + /** 跳转/打开失败时的提示回调(默认 noop)。 */ + onError?: (message: string) => void; +} + +/** + * 执行一个跳转按钮: + * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 + * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 + */ +export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise { + const fail = (m: string) => ctx.onError?.(m); + // purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。 + if (btn.linkType === "purpose") return; + if (btn.linkType === "url") { + const base = btn.target.trim(); + if (!base) { + fail(`未配置「${btn.label}」的跳转地址`); + return; + } + // 行动分析(xdfx)可选追加行动id;否则用任务id。行动id 缺失时回退任务id。 + const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId; + const param = (btn.idParam || "id").trim() || "id"; + const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base; + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + // business:内部切换到另一业务 + const detail = readCopTaskDetail(); + if (!detail || !ctx.taskId) { + fail("缺少任务信息,无法跳转"); + return; + } + try { + const target = await resolveTaskDeeplinkTarget(btn.target, detail, ctx.taskId); + if (target) ctx.navigate(target.path, { state: target.state }); + else fail(`未配置「${btn.label}」对应的业务智能体`); + } catch { + fail("跳转失败,请稍后再试"); + } +} diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index e9fe6bd5..08e73e69 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -174,13 +174,16 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string): string { * ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。 * 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。 * - * @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与 - * consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。 + * @param taskId 真实任务 id(xdfx 已由 action 接口解析;其它业务即深链原始 ?taskId=)。 + * 用于按任务隔离草稿/对话;与 consumer 返回的 detail.id 区分。 + * @param rawId 深链 URL 的原始 ``?taskId=``(xdfx 为「行动id」)。供需追加行动id 的按钮用; + * 省略时回退 taskId。 */ export async function resolveTaskDeeplinkTarget( goPath: string, detail: CopTaskDetail, taskId: string, + rawId?: string, ): Promise { const parsed = parseGoPath(goPath); if (!parsed) return null; @@ -220,6 +223,11 @@ export async function resolveTaskDeeplinkTarget( // 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定 // 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表, // 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。 - persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail, taskId) }); + persistTaskContext({ + taskId, + agentId, + openingText: buildOpeningText(detail, taskId), + actionId: rawId || taskId, + }); 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 a8f91a85..d626d416 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -33,6 +33,8 @@ export interface TaskContext { agentId: string; /** 自动发出的开场信息(任务名称/内容)。 */ openingText: string; + /** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */ + actionId?: string; } const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext"; @@ -216,6 +218,7 @@ export function readTaskContext(): TaskContext | null { taskId: String(parsed.taskId), agentId: String(parsed.agentId), openingText: typeof parsed.openingText === "string" ? parsed.openingText : "", + actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined, }; } catch { return null; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 0b1ef2ff..b169212e 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -38,6 +38,7 @@ export default function AgentChatPage({ chatBasePath, taskGate, taskBind, + autoStartWhenPurposeEmpty, }: { /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ @@ -53,6 +54,8 @@ export default function AgentChatPage({ /** 3qfx / xdfx 任务深链:仅做任务↔对话 1:N 绑定(打 `metadata.taskId` 标签 + 切换会话停旧 run), * 不展示目的树闸门;新建对话直接把任务开场信息自动发出。与 `taskGate` 互斥使用。 */ taskBind?: boolean; + /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ + autoStartWhenPurposeEmpty?: boolean; } = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); @@ -315,7 +318,11 @@ export default function AgentChatPage({ {showTaskGate && ( - + )} {isLandingState ? ( diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 0550e2f7..45009d45 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -168,7 +168,7 @@ export default function LoginPage() { if (cancelled) return; if (detail) { persistCopTaskDetail(detail); - const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId); + const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId); if (cancelled) return; if (target) { navigate(target.path, { replace: true, state: target.state }); diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index e467773f..b2c07d21 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -39,6 +39,7 @@ import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; +import TaskButtonsPage from "./TaskButtonsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage"; @@ -135,7 +136,7 @@ export default function StrategyRoutes() { + } > + } > + } /> @@ -214,7 +218,7 @@ export default function StrategyRoutes() { + } > + + ) : ( + + ) + } + /> `${b}-custom-${Date.now()}-${_seq++}`; + +/** 归一化用于「脏」判断(按 id 排序后整体比较)。 */ +function serialize(rows: TaskButton[]): string { + return JSON.stringify([...rows].sort((a, b) => a.id.localeCompare(b.id))); +} + +export default function TaskButtonsPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const { data: serverButtons = [], isLoading } = useTaskButtons(); + const { save } = useTaskButtonActions(); + + const [cfg, setCfg] = useState(() => DEFAULT_TASK_DEEPLINK_CONFIG); + useEffect(() => { + let alive = true; + void fetchTaskDeeplinkConfig().then((c) => { + if (alive) setCfg(c); + }); + return () => { + alive = false; + }; + }, []); + + const [rows, setRows] = useState([]); + const [loadedKey, setLoadedKey] = useState(null); + const [tab, setTab] = useState("rwfx"); + + // 服务端首次/变化时回填工作副本(空表回填种子默认)。以序列化值判定,避免覆盖未保存编辑。 + const sourceKey = useMemo(() => serialize(serverButtons), [serverButtons]); + useEffect(() => { + if (sourceKey !== loadedKey) { + setRows(serverButtons.length > 0 ? serverButtons : defaultTaskButtons(cfg)); + setLoadedKey(sourceKey); + } + }, [sourceKey, loadedKey, serverButtons, cfg]); + + const tabRows = useMemo( + () => rows.filter((r) => r.business === tab).sort((a, b) => a.sortOrder - b.sortOrder), + [rows, tab], + ); + + const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]); + + if (!isAdmin) return ; + + const update = (id: string, patch: Partial) => + setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); + + const removeRow = (id: string) => setRows((rs) => rs.filter((r) => r.id !== id)); + + const addRow = () => { + const maxSort = tabRows.reduce((m, r) => Math.max(m, r.sortOrder), -1); + setRows((rs) => [ + ...rs, + { + id: newId(tab), + business: tab, + label: "新按钮", + linkType: "url", + target: "", + appendTaskId: true, + idParam: "id", + idKind: "task", + enabled: true, + sortOrder: maxSort + 1, + }, + ]); + }; + + const moveRow = (id: string, dir: "up" | "down") => { + const list = [...tabRows]; + const i = list.findIndex((r) => r.id === id); + const j = dir === "up" ? i - 1 : i + 1; + if (i < 0 || j < 0 || j >= list.length) return; + [list[i], list[j]] = [list[j], list[i]]; + const reordered = list.map((r, idx) => ({ ...r, sortOrder: idx })); + setRows((rs) => [...rs.filter((r) => r.business !== tab), ...reordered]); + }; + + const handleSave = async () => { + const payload = rows.map((r) => ({ ...r, label: r.label.trim() })); + try { + await save.mutateAsync(payload); + toast.success("已保存按钮配置"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } + }; + + const importDefaults = (business?: TaskBusiness) => { + const have = new Set(rows.map((r) => r.id)); + const adds = defaultTaskButtons(cfg) + .filter((d) => !business || d.business === business) + .filter((d) => !have.has(d.id)); + if (!adds.length) { + toast.info("没有可导入的默认按钮"); + return; + } + setRows((rs) => [...rs, ...adds]); + toast.success(`已导入 ${adds.length} 个默认按钮`); + }; + + return ( +
+
+
+ +
+
按钮管理
+
+ 配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序) +
+
+
+
+ + +
+
+ + {/* 业务 tab:每个 tab = 一个任务深链页面,明确区分三种页面的按钮 */} +
+ {TASK_BUSINESSES.map((b) => { + const count = rows.filter((r) => r.business === b.code).length; + const active = tab === b.code; + return ( + + ); + })} +
+ + {/* 当前页面说明 */} + {(() => { + const meta = TASK_BUSINESSES.find((b) => b.code === tab)!; + return ( +
+ 正在编辑「{meta.name} + 」页面的按钮 · {meta.desc} 下方按钮会显示在该页面底部的「快捷跳转」栏,从上到下对应栏内从左到右的顺序。 +
+ ); + })()} + +
+ {isLoading ? ( +
加载中…
+ ) : tabRows.length === 0 ? ( +
+ 该页面暂无按钮。可「导入该页面默认按钮」一键填充,或点下方「添加按钮」自定义。 + +
+ ) : ( +
+ {tabRows.map((r, idx) => ( +
+
+
+ + update(r.id, { label: e.target.value })} + placeholder="按钮名称" + className="h-8 max-w-xs" + /> + + +
+ +
+ + {r.linkType === "purpose" ? ( + + 点击弹出当前任务的「任务目的」表格,无需配置目标。 + + ) : r.linkType === "url" ? ( + <> + update(r.id, { target: e.target.value })} + placeholder="https://…(跳转地址)" + className="h-8 flex-1 min-w-[16rem]" + /> + + {r.appendTaskId && ( +
+ ? + update(r.id, { idParam: e.target.value })} + placeholder="id" + className="h-8 w-24" + title="查询参数名(如 id / task_id)" + /> + = + {/* 行动分析(xdfx):行动id 与任务id 不同,可选追加哪种;其它业务固定任务id */} + {tab === "xdfx" ? ( + + ) : ( + 任务id + )} +
+ )} + + ) : ( + + )} +
+
+ +
+ + + + +
+
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/frontend-web/src/strategy-components/api/task-buttons.ts b/frontend-web/src/strategy-components/api/task-buttons.ts new file mode 100644 index 00000000..4e651388 --- /dev/null +++ b/frontend-web/src/strategy-components/api/task-buttons.ts @@ -0,0 +1,114 @@ +/** + * 任务工作区可配置跳转按钮(按钮管理)API 客户端 + React Query hooks。 + * + * 后端:`/api/task-buttons`(见 app/gateway/routers/task_buttons.py)。 + * 全局共享、无用户隔离;读对所有登录用户开放(工作区要用),写仅 admin(整体替换)。 + * snake_case ⇄ camelCase 在此边界收敛。见 [[task-buttons]]。 + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import type { + TaskBusiness, + TaskButton, + TaskButtonIdKind, + TaskButtonLinkType, +} from "@/core/auth/task-buttons"; + +const BASE = () => `${getBackendBaseURL()}/api/task-buttons`; + +interface ServerButton { + id: string; + business: string; + label: string; + link_type: string; + target: string; + append_task_id: boolean; + id_param: string; + id_kind: string; + enabled: boolean; + sort_order: number; +} + +function toButton(s: ServerButton): TaskButton { + return { + id: s.id, + business: (s.business as TaskBusiness) ?? "rwfx", + label: s.label ?? "", + linkType: (s.link_type as TaskButtonLinkType) ?? "url", + target: s.target ?? "", + appendTaskId: s.append_task_id ?? true, + idParam: s.id_param || "id", + idKind: (s.id_kind as TaskButtonIdKind) ?? "task", + enabled: s.enabled ?? true, + sortOrder: s.sort_order ?? 0, + }; +} + +function toBody(b: TaskButton): ServerButton { + return { + id: b.id, + business: b.business, + label: b.label, + link_type: b.linkType, + target: b.target, + append_task_id: b.appendTaskId, + id_param: b.idParam, + id_kind: b.idKind, + enabled: b.enabled, + sort_order: b.sortOrder, + }; +} + +async function readJson(res: Response, ctx: string): Promise { + if (!res.ok) { + const data = await res.json().catch(() => null as unknown); + const detail = + data && typeof data === "object" && "detail" in data + ? String((data as { detail: unknown }).detail) + : `${ctx} failed (${res.status})`; + throw new Error(detail); + } + return (await res.json()) as T; +} + +export async function listTaskButtons(): Promise { + const res = await apiFetch(BASE(), { method: "GET" }); + const data = await readJson<{ buttons: ServerButton[] }>(res, "list task buttons"); + return (data.buttons ?? []).map(toButton); +} + +export async function replaceTaskButtons(buttons: TaskButton[]): Promise { + const res = await apiFetch(BASE(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buttons: buttons.map(toBody) }), + }); + const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons"); + return (data.buttons ?? []).map(toButton); +} + +// ── React Query hooks(工作区 + 管理页共用缓存) ──────────────────────────── + +const TASK_BUTTONS_KEY = "task-buttons"; + +export function useTaskButtons() { + return useQuery({ + queryKey: [TASK_BUTTONS_KEY], + queryFn: listTaskButtons, + refetchOnWindowFocus: false, + staleTime: 60_000, + }); +} + +export function useTaskButtonActions() { + const queryClient = useQueryClient(); + return { + save: useMutation({ + mutationFn: replaceTaskButtons, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [TASK_BUTTONS_KEY] }), + }), + }; +} diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index 3a1dd031..637dad94 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -16,7 +16,7 @@ import { useEffect, useMemo, useState } from "react"; import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom"; -import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react"; +import { ArrowRight, ExternalLink, Loader2, Maximize2, MessageSquare, PlusSquare, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -28,20 +28,27 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; +import { readCopTaskDetail } from "@/core/auth/task-deeplink"; import { - buildTreemapEditUrl, - buildTreemapNextUrl, - deleteTaskPurpose, - readTaskContext, -} from "@/core/auth/task-purpose"; -import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink"; -import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; + DEFAULT_TASK_DEEPLINK_CONFIG, + fetchTaskDeeplinkConfig, + type TaskDeeplinkConfig, +} from "@/core/auth/task-deeplink-config"; +import { + buttonsForBusiness, + resolveTaskButtons, + runTaskButton, + type TaskBusiness, +} from "@/core/auth/task-buttons"; +import { useTaskButtons } from "@/strategy-components/api/task-buttons"; import { useTaskThreads } from "@/core/threads/use-task-threads"; import { titleOfThread } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import AgentChatPage from "@/pages/AgentChatPage"; import { ChatRuntime } from "@/pages/WorkspaceRoutes"; +import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel"; function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } { const { agent_id } = useParams<{ agent_id: string }>(); @@ -90,8 +97,72 @@ function parseThreadId(pathname: string, chatsBase: string): string | null { } /** - * 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」+(rwfx)跳转三情分析;下方对话列表(1:N)。 - * ``lite``(3qfx/xdfx)隐藏跳转按钮与底部任务操作面板。 + * 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。 + * 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。 + */ +function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) { + const navigate = useNavigate(); + const [cfg, setCfg] = useState(() => DEFAULT_TASK_DEEPLINK_CONFIG); + useEffect(() => { + let alive = true; + void fetchTaskDeeplinkConfig().then((c) => { + if (alive) setCfg(c); + }); + return () => { + alive = false; + }; + }, []); + const { data: storedButtons = [] } = useTaskButtons(); + const jumpButtons = useMemo( + () => buttonsForBusiness(resolveTaskButtons(storedButtons, cfg), business), + [storedButtons, cfg, business], + ); + + // 目的树弹框(purpose 类型按钮)由组件层打开。 + const [purposeOpen, setPurposeOpen] = useState(false); + + if (jumpButtons.length === 0) return null; + + const handleRun = (btn: (typeof jumpButtons)[number]) => { + if (btn.linkType === "purpose") { + setPurposeOpen(true); + return; + } + void runTaskButton(btn, { + taskId, + actionId: readTaskContext()?.actionId, + navigate, + onError: (m) => toast.error(m), + }); + }; + + return ( +
+ {jumpButtons.map((btn) => ( + + ))} + +
+ ); +} + +/** + * 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。 + * 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。 */ function TaskConversationsList({ taskId, @@ -109,22 +180,6 @@ function TaskConversationsList({ const { threads, isLoading } = useTaskThreads(taskId, agentId); const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]); - // rwfx:带当前任务(同一 taskId)跳到三情分析工作区。复用深链解析(会按 3Q 业务码重绑任务上下文)。 - const handleJumpToSentiment = async () => { - const detail = readCopTaskDetail(); - if (!detail || !taskId) { - toast.error("缺少任务信息,无法跳转三情分析"); - return; - } - try { - const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); - if (target) navigate(target.path, { state: target.state }); - else toast.error("未配置三情分析智能体"); - } catch { - toast.error("跳转失败,请稍后再试"); - } - }; - return (
@@ -144,17 +199,7 @@ function TaskConversationsList({ > 新建对话 - {!lite && ( - - )} + {!lite && }
@@ -191,24 +236,18 @@ function TaskConversationsList({ )) )}
- {!lite && }
); } /** - * 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。 - * 跟随任务对话面板布局,不再覆盖右侧聊天输入框。 + * 「清除记录」按钮(rwfx):放在「新建对话」下面,仅调 delete 接口清空 B 系统目的策划数据 + * (聊天对话保留)。带二次确认弹框。 */ -function TaskActionsPanel({ taskId }: { taskId: string }) { +function TaskClearButton({ taskId }: { taskId: string }) { const [clearOpen, setClearOpen] = useState(false); const [clearing, setClearing] = useState(false); - const openExternal = (url: string, label: string) => { - if (url) window.open(url, "_blank", "noopener,noreferrer"); - else toast.error(`未配置${label}地址`); - }; - const handleClear = async () => { setClearing(true); try { @@ -225,44 +264,16 @@ function TaskActionsPanel({ taskId }: { taskId: string }) { return ( <> -
-
-
- 任务操作 -
-
- - - -
-
-
+ !clearing && setClearOpen(o)}> @@ -290,18 +301,30 @@ function TaskActionsPanel({ taskId }: { taskId: string }) { * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 */ -export function TaskFxWorkspace({ taskBase, lite }: { taskBase: string; lite?: boolean }) { +export function TaskFxWorkspace({ + taskBase, + business, + lite, +}: { + taskBase: string; + business: TaskBusiness; + lite?: boolean; +}) { const { taskId, agentId, chatsBase } = useChatsBase(taskBase); - // 预拉配置,保证(rwfx)「编辑/下一步」的同步 URL 构造能读到缓存。 - useEffect(() => { - void fetchTaskDeeplinkConfig(); - }, []); return ( -
- -
- +
+
+ +
+ +
+
); } @@ -328,12 +351,25 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) { ); } -/** 右侧对话宿主(rwfx):AgentChatPage taskGate → 新建对话顶部展示目的树闸门面板 + 老对话停止。 */ -export function TaskFxChat({ taskBase }: { taskBase: string }) { +/** + * 右侧对话宿主(rwfx / xdfx):AgentChatPage taskGate → 新建对话顶部展示目的树闸门面板 + 老对话停止。 + * ``autoStartWhenPurposeEmpty``(rwfx):查询任务目的为空数据时自动开始第一次问答。 + */ +export function TaskFxChat({ + taskBase, + autoStartWhenPurposeEmpty, +}: { + taskBase: string; + autoStartWhenPurposeEmpty?: boolean; +}) { const { agent_id } = useParams<{ agent_id: string }>(); return ( - + ); } diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index 6ed516ea..178b13a5 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -10,8 +10,8 @@ * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。 */ -import { useEffect, useMemo, useState } from "react"; -import { ExternalLink, Loader2, Maximize2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronDown, ChevronRight, Loader2, Maximize2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -24,7 +24,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { - buildDetail3qUrl, deleteTaskPurpose, fetchTaskPurposeDetail, flattenPurposeTree, @@ -34,7 +33,7 @@ import { import { cn } from "@/lib/utils"; /** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */ -function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) { +export function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) { const rows = useMemo(() => flattenPurposeTree(tree), [tree]); const headers = useMemo(() => { const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0); @@ -91,26 +90,86 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className? ); } +/** + * 独立的「任务目的弹框」:按需拉取目的树并放大展示。供底部「快捷跳转」栏的 ``purpose`` 类型 + * 按钮复用(任意页面可配置「弹框查看」)。打开时才拉取。 + */ +export function TaskPurposeDialog({ + taskId, + open, + onOpenChange, +}: { + taskId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + fetchTaskPurposeDetail(taskId) + .then((r) => !cancelled && setResult(r)) + .catch(() => !cancelled && setResult({ tree: [], hasData: false })) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [open, taskId]); + + return ( + + + + 任务目的(任务 {taskId}) + + {loading ? ( +
+ 正在查询任务目的… +
+ ) : ( + + )} +
+
+ ); +} + export function TaskPurposePanel({ taskId, onStart, + autoStartWhenEmpty, }: { taskId: string; /** 闸门通过(无数据 / 覆盖成功),开始问答。 */ onStart: () => void; + /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ + autoStartWhenEmpty?: boolean; }) { const [loading, setLoading] = useState(true); const [result, setResult] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [working, setWorking] = useState(false); const [zoomOpen, setZoomOpen] = useState(false); + // 任务目的默认收起(不展开),点标题切换。 + const [collapsed, setCollapsed] = useState(true); + // rwfx 空数据自动开始:防止重复触发。 + const autoStartedRef = useRef(false); useEffect(() => { let cancelled = false; setLoading(true); fetchTaskPurposeDetail(taskId) .then((r) => { - if (!cancelled) setResult(r); + if (cancelled) return; + setResult(r); + // 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。 + if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) { + autoStartedRef.current = true; + onStart(); + } }) .catch(() => { if (!cancelled) setResult({ tree: [], hasData: false }); @@ -121,6 +180,8 @@ export function TaskPurposePanel({ return () => { cancelled = true; }; + // onStart/autoStartWhenEmpty 在一次新建会话内稳定;仅按 taskId 重新拉取。 + // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId]); const handleStart = () => { @@ -152,37 +213,39 @@ export function TaskPurposePanel({ return (
- 任务目的(任务 {taskId}) +
{hasTree && ( )} -
- {loading ? ( -
- 正在查询任务目的… -
- ) : ( - - )} + {!collapsed && + (loading ? ( +
+ 正在查询任务目的… +
+ ) : ( + + ))} {/* 弹框查看:放大显示完整表格 */} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 827f4398..a8e79379 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -254,6 +254,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. | | **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. | | **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. | +| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), enabled, sort_order}`. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). | Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index f624389b..7d88b150 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -69,6 +69,7 @@ from app.gateway.routers import ( suggestions, system_settings, tags, + task_buttons, thread_runs, tool_metrics, thread_shares, @@ -1348,6 +1349,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words app.include_router(sensitive_words.router) + # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons + app.include_router(task_buttons.router) + # Standalone parallel multi-agent panel: fan-out streaming + saved tasks app.include_router(parallel_agents.router) app.include_router(parallel_agent_tasks.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index babc3932..24f6927d 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -247,6 +247,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.sensitive_word_store = make_sensitive_word_store(sf) + from deerflow.persistence.task_buttons import make_task_button_store + + app.state.task_button_store = make_task_button_store(sf) + from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf) diff --git a/offline-backend-20260512/backend/app/gateway/routers/task_buttons.py b/offline-backend-20260512/backend/app/gateway/routers/task_buttons.py new file mode 100644 index 00000000..73b4f44e --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/task_buttons.py @@ -0,0 +1,104 @@ +"""Task-workspace configurable jump-button API (按钮管理). + +The task deep-link workspaces (rwfx / 3qfx / xdfx, see the frontend +``TaskFxWorkspace``) render configurable jump buttons in their left task panel. +Their default targets historically lived in ``config.yaml`` ``task_deeplink`` and +their names were hardcoded; this router persists a **global / shared** button +set the frontend renders instead, so an admin can rename / retarget / add / +remove jump buttons per business. + +When the table is empty (fresh deployment, never saved) the frontend falls back +to code-defined seed defaults built from the live task-deeplink config; once +saved, this table is the source of truth. + +Reads are open to any authenticated user (the workspace needs them); the whole +button set is replaced atomically and only by an admin. + +Routes (prefix ``/api/task-buttons``): + GET "" list all buttons + PUT "" replace the entire button set (admin) +""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.deps import get_current_user, get_optional_user_from_request +from deerflow.persistence.task_buttons import TaskButtonStore + +router = APIRouter(prefix="/api/task-buttons", tags=["task-buttons"]) + + +# ── schemas ────────────────────────────────────────────────────────────── + + +class TaskButton(BaseModel): + id: str = Field(..., min_length=1, max_length=128) + business: str = Field(..., min_length=1, max_length=32) + label: str = Field(default="", max_length=255) + link_type: str = Field(default="url", max_length=16) + target: str = Field(default="", max_length=1024) + append_task_id: bool = True + # 追加的查询参数名(如 id / task_id)。 + id_param: str = Field(default="id", max_length=64) + # task=任务id(默认)/ action=行动id(仅 xdfx 二者不同)。 + id_kind: str = Field(default="task", max_length=16) + enabled: bool = True + sort_order: int = 0 + + +class TaskButtonResponse(TaskButton): + updated_by: str | None = None + updated_at: datetime | str | None = None + + +class TaskButtonListResponse(BaseModel): + buttons: list[TaskButtonResponse] + + +class TaskButtonReplaceRequest(BaseModel): + buttons: list[TaskButton] = Field(default_factory=list) + + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _get_store(request: Request) -> TaskButtonStore: + store = getattr(request.app.state, "task_button_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Task button store not available") + return store + + +async def _require_admin(request: Request) -> None: + """Reject non-admins. Lets the call through when auth is disabled.""" + user = await get_optional_user_from_request(request) + if user is None: + return + if getattr(user, "system_role", None) != "admin": + raise HTTPException(status_code=403, detail="按钮管理仅限管理员") + + +# ── routes ─────────────────────────────────────────────────────────────── + + +@router.get("", response_model=TaskButtonListResponse) +async def list_task_buttons(request: Request) -> TaskButtonListResponse: + store = _get_store(request) + rows = await store.list_buttons() + return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows]) + + +@router.put("", response_model=TaskButtonListResponse) +async def replace_task_buttons(request: Request, body: TaskButtonReplaceRequest) -> TaskButtonListResponse: + await _require_admin(request) + store = _get_store(request) + user_id = await get_current_user(request) + rows = await store.replace_all( + [b.model_dump() for b in body.buttons], + updated_by=user_id, + ) + return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows]) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 849fcb4b..7574cfb7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -64,6 +64,7 @@ from deerflow.persistence.scheduled_tasks.model import ( SchedulerThreadRow, ) from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow +from deerflow.persistence.task_buttons.model import TaskButtonRow from deerflow.persistence.tags.model import TagAssignmentRow, TagRow from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.embed_sessions.model import EmbedSessionRow @@ -119,6 +120,7 @@ __all__ = [ "SkillFavoriteRow", "TagAssignmentRow", "TagRow", + "TaskButtonRow", "EmbedSessionRow", "ThreadMetaRow", "ThreadShareRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py new file mode 100644 index 00000000..ba3e2a19 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py @@ -0,0 +1,22 @@ +"""Task-workspace configurable jump-button persistence (按钮管理).""" + +from __future__ import annotations + +from deerflow.persistence.task_buttons.base import TaskButtonStore +from deerflow.persistence.task_buttons.memory import MemoryTaskButtonStore +from deerflow.persistence.task_buttons.model import TaskButtonRow +from deerflow.persistence.task_buttons.sql import TaskButtonRepository + +__all__ = [ + "MemoryTaskButtonStore", + "TaskButtonRepository", + "TaskButtonRow", + "TaskButtonStore", + "make_task_button_store", +] + + +def make_task_button_store(session_factory): + if session_factory is None: + return MemoryTaskButtonStore() + return TaskButtonRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py new file mode 100644 index 00000000..a98ed462 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py @@ -0,0 +1,30 @@ +"""Abstract task-workspace button persistence API. + +Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the +router gates writes behind admin. ``updated_by`` is audit-only. + +The whole button set is treated as one document: the editor computes the full +desired layout and replaces every row atomically via :meth:`replace_all`, so the +stored set never drifts out of sync with what the admin sees. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class TaskButtonStore(ABC): + @abstractmethod + async def list_buttons(self) -> list[dict[str, Any]]: + """Return every stored button row (order unspecified).""" + raise NotImplementedError + + @abstractmethod + async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + """Atomically replace the entire button set with ``rows``. + + Each row is ``{id, business, label, link_type, target, append_task_id, + enabled, sort_order}``. Returns the persisted rows. + """ + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py new file mode 100644 index 00000000..a775e169 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py @@ -0,0 +1,42 @@ +"""In-memory task-button store (used when no DB is configured / tests).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from deerflow.persistence.task_buttons.base import TaskButtonStore + + +def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]: + return { + "id": str(data["id"]), + "business": str(data.get("business") or ""), + "label": str(data.get("label") or ""), + "link_type": str(data.get("link_type") or "url"), + "target": str(data.get("target") or ""), + "append_task_id": bool(data.get("append_task_id", True)), + "id_param": str(data.get("id_param") or "id"), + "id_kind": str(data.get("id_kind") or "task"), + "enabled": bool(data.get("enabled", True)), + "sort_order": int(data.get("sort_order", 0)), + "updated_by": updated_by, + "updated_at": datetime.now(UTC).isoformat(), + } + + +class MemoryTaskButtonStore(TaskButtonStore): + def __init__(self) -> None: + self._rows: dict[str, dict[str, Any]] = {} + + async def list_buttons(self) -> list[dict[str, Any]]: + return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))] + + async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + self._rows = {} + for r in rows: + row_id = r.get("id") + if not row_id: + continue + self._rows[str(row_id)] = _normalize(r, updated_by) + return [dict(r) for r in self._rows.values()] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py new file mode 100644 index 00000000..f68f413a --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py @@ -0,0 +1,54 @@ +"""ORM model for task-workspace configurable jump buttons (按钮管理). + +Global / shared — there is no ``user_id`` scoping; every account sees the same +buttons. The task deep-link workspaces (rwfx / 3qfx / xdfx, see +``TaskFxWorkspace``) render the enabled buttons for their ``business`` in the +left task panel. Each row is one button: + +- ``business`` — which workspace the button belongs to (``rwfx`` / ``3qfx`` / + ``xdfx``). +- ``label`` — display name. +- ``link_type`` — ``url`` (external, opened in a new tab) or ``business`` + (internal switch to another deep-link business). +- ``target`` — the full URL (``url``) or the target business code (``business``). +- ``append_task_id`` — for ``url`` buttons, whether to append ``?id={taskId}``. +- ``enabled`` — 停用 parks a button without deleting it. +- ``sort_order`` — position among the business's buttons. + +When the whole table is empty (fresh deployment, never saved) the frontend +falls back to code-defined seed defaults built from the live task-deeplink +config; once saved, this table is the source of truth. ``updated_by`` is +audit-only. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base +from deerflow.persistence.types import BeijingDateTime + + +class TaskButtonRow(Base): + __tablename__ = "task_buttons" + + id: Mapped[str] = mapped_column(String(128), primary_key=True) + business: Mapped[str] = mapped_column(String(32), index=True) + label: Mapped[str] = mapped_column(String(255), default="") + link_type: Mapped[str] = mapped_column(String(16), default="url") + target: Mapped[str] = mapped_column(String(1024), default="") + append_task_id: Mapped[bool] = mapped_column(Boolean, default=True) + # 追加的查询参数名(如 ``id`` / ``task_id``)。可空,读时归一化为 ``id``。 + id_param: Mapped[str | None] = mapped_column(String(64), default="id") + # 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同:深链原始 + # ?taskId= 实为行动id,任务id 由 action 接口解析)。可空,读时归一化为 ``task``。 + id_kind: Mapped[str | None] = mapped_column(String(16), default="task") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + updated_by: Mapped[str | None] = mapped_column(String(64)) + updated_at: Mapped[datetime] = mapped_column( + BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py new file mode 100644 index 00000000..be498f1c --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py @@ -0,0 +1,66 @@ +"""SQLAlchemy-backed task-button store (global / shared).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.task_buttons.base import TaskButtonStore +from deerflow.persistence.task_buttons.model import TaskButtonRow + + +def _to_dict(row: TaskButtonRow) -> dict[str, Any]: + data = row.to_dict() + value = data.get("updated_at") + if isinstance(value, datetime): + data["updated_at"] = value.isoformat() + # 旧行 / NULL 归一化为默认值。 + data["id_param"] = data.get("id_param") or "id" + data["id_kind"] = data.get("id_kind") or "task" + return data + + +def _row_from(data: dict[str, Any], updated_by: str | None) -> TaskButtonRow: + return TaskButtonRow( + id=str(data["id"]), + business=str(data.get("business") or ""), + label=str(data.get("label") or ""), + link_type=str(data.get("link_type") or "url"), + target=str(data.get("target") or ""), + append_task_id=bool(data.get("append_task_id", True)), + id_param=str(data.get("id_param") or "id"), + id_kind=str(data.get("id_kind") or "task"), + enabled=bool(data.get("enabled", True)), + sort_order=int(data.get("sort_order", 0)), + updated_by=updated_by, + ) + + +class TaskButtonRepository(TaskButtonStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def list_buttons(self) -> list[dict[str, Any]]: + stmt = select(TaskButtonRow).order_by(TaskButtonRow.sort_order.asc()) + async with self._sf() as session: + result = await session.execute(stmt) + return [_to_dict(row) for row in result.scalars()] + + async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + # De-dupe by id (last wins) so a malformed payload can't violate the PK. + by_id: dict[str, dict[str, Any]] = {} + for r in rows: + row_id = r.get("id") + if row_id: + by_id[str(row_id)] = r + async with self._sf() as session: + await session.execute(delete(TaskButtonRow)) + new_rows = [_row_from(r, updated_by) for r in by_id.values()] + session.add_all(new_rows) + await session.commit() + for row in new_rows: + await session.refresh(row) + return [_to_dict(row) for row in new_rows]