# Conflicts:
#	CLAUDE.md
This commit is contained in:
fengxiangdebaozi committed 2026-06-18 17:53:23 +08:00
commit 6a8f118ad0
32 files changed
+1729 -181

No files matched your search

+2 -1
View File
@@ -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-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **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/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `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 Line truncated
### Configuration
@@ -8,6 +8,7 @@ import {
Folder,
FolderOpen,
ListTree,
MousePointerClick,
Settings,
ShieldAlert,
User,
@@ -213,6 +214,14 @@ function SecondaryNavSidebar() {
菜单管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/task-buttons")}
>
<MousePointerClick className="size-4" />
按钮管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
@@ -493,6 +502,12 @@ function LegacyAccordionSidebar({
菜单管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
<MousePointerClick className="size-4" />
按钮管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" />
@@ -131,14 +131,17 @@ export function AgentDetailPanel({
agentName,
onAskQuestion,
disabled,
defaultCollapsed,
}: {
agent: Agent | null | undefined;
agentName: string;
onAskQuestion: (text: string) => void;
disabled?: boolean;
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
defaultCollapsed?: boolean;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
const [mobileOpen, setMobileOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<AgentDraft | null>(null);
+246
View File
@@ -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<string, unknown> }) => void;
/** 跳转/打开失败时的提示回调(默认 noop)。 */
onError?: (message: string) => void;
}
/**
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
*/
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
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("跳转失败,请稍后再试");
}
}
@@ -19,6 +19,8 @@ export interface TaskDeeplinkConfig {
test: boolean;
/** 删除接口走假数据(该接口未开发完时打开)。 */
deleteTest: boolean;
/** 行动→任务解析走假数据(xdfx 行动id→任务id)。 */
actionTest: boolean;
/** 目的树查询完整 URL(调用追加 ?taskId=)。 */
purposeDetailUrl: string;
/** 删除完整 URL(调用追加 ?taskId=)。 */
@@ -27,10 +29,16 @@ export interface TaskDeeplinkConfig {
treemapEditUrl: string;
/** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
treemapNextUrl: string;
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */
/** 行动详情接口基址(xdfx 行动→任务):调用追加 /{actionId},返回 data.taskId 为真正任务id。 */
actionDetailUrl: string;
/** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */
detail3qUrl: string;
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success};
* actionDetail = 原始 {state,msg,data:{taskId,...}}。 */
mock: {
purposeDetail?: unknown;
delete?: { message?: string; success?: boolean };
actionDetail?: unknown;
};
}
@@ -38,10 +46,13 @@ export interface TaskDeeplinkConfig {
export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
test: false,
deleteTest: false,
actionTest: false,
purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action",
detail3qUrl: "",
mock: {},
};
@@ -55,16 +66,21 @@ function normalize(body: unknown): TaskDeeplinkConfig {
return {
test: o.test === true,
deleteTest: o.delete_test === true,
actionTest: o.action_test === true,
purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl),
deleteUrl: str(o.delete_url, d.deleteUrl),
treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl),
// detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。
detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl,
mock: {
purposeDetail: mock.purpose_detail,
delete:
mock.delete && typeof mock.delete === "object"
? (mock.delete as { message?: string; success?: boolean })
: undefined,
actionDetail: mock.action_detail,
},
};
}
+58 -23
View File
@@ -17,10 +17,11 @@
import { requestCrudService } from "@/service/request-axios-service";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */
const RWFX_BUSINESS_CODE = "6BF";
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
const XDFX_BUSINESS_CODE = "7BF";
/** consumer 接口返回的任务详情(data 字段)。 */
export interface CopTaskDetail {
@@ -110,6 +111,44 @@ export function readCopTaskDetail(): CopTaskDetail | null {
}
}
/**
* xdfx 深链:用「行动id」调行动详情接口 ``{action_detail_url}/{actionId}``,取出 ``data.taskId``
* 作为真正的「任务id」。``action_test=true`` 时直接用后端配置里的假数据(接口未通时用)。
* 缺地址 / 返回不合法 → 返回 ""(调用方据此回退)。
*/
export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
if (!actionId) return "";
const cfg = await fetchTaskDeeplinkConfig();
let body: unknown;
if (cfg.actionTest) {
body = cfg.mock.actionDetail;
} else if (cfg.actionDetailUrl) {
const base = cfg.actionDetailUrl.replace(/\/+$/, "");
// service 拦截器已统一注入 Authorization: Bearer admin 并返回整包 body;行动id 走路径段。
body = await requestCrudService.get(`${base}/${encodeURIComponent(actionId)}`);
} else {
return "";
}
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return "";
const taskId = (data as Record<string, unknown>).taskId;
return taskId != null && taskId !== "" ? String(taskId) : "";
}
/**
* 据 goPath 把深链 URL 里的原始 id 解析成真正的「任务id」:
* - xdfx(行动分析):URL 传入的是「行动id」,调 action 接口换成 ``data.taskId``;
* 解析失败返回 ""(让调用方回退默认首页,避免拿行动id当任务id污染后续查询/绑定)。
* - 其它业务:URL 传入的本就是任务id,原样返回。
*/
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
return fetchTaskIdFromAction(rawId);
}
return rawId;
}
export interface TaskDeeplinkTarget {
path: string;
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
@@ -135,20 +174,23 @@ 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<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
// 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
clearTaskContext();
if (parsed.roundtable) {
@@ -177,22 +219,15 @@ export async function resolveTaskDeeplinkTarget(
}
if (!agentId) return null;
if (parsed.businessCode === RWFX_BUSINESS_CODE) {
// rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引,
// 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage:
// 开场信息在闸门通过后由目的树面板发出。
persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail, taskId) });
return { path: `${taskBase}/agent/${agentId}/chats` };
}
// 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId, text: buildOpeningText(detail, taskId) }),
);
} catch {
/* noop */
}
return { path: `${taskBase}/agent/${agentId}/chats/new` };
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
persistTaskContext({
taskId,
agentId,
openingText: buildOpeningText(detail, taskId),
actionId: rawId || taskId,
});
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -39,6 +39,8 @@ export interface TaskContext {
agentId: string;
/** 自动发出的开场信息(任务名称/内容)。 */
openingText: string;
/** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */
actionId?: string;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
@@ -273,6 +275,12 @@ export function buildTreemapNextUrl(taskId: string): string {
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
}
/** 「3q详情」详情页地址(目的树面板链接,新标签打开);追加 ?id=任务id。地址未配置时返回 ""。 */
export function buildDetail3qUrl(taskId: string): string {
const url = getCachedTaskDeeplinkConfig().detail3qUrl;
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
}
/* --------------------------- 任务上下文暂存 --------------------------- */
export function persistTaskContext(ctx: TaskContext): void {
@@ -293,6 +301,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;
+4 -1
View File
@@ -785,7 +785,10 @@ export function useThreadStream({
} as typeof thread)
: thread;
return [mergedThread, sendMessage, isUploading] as const;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
// (submit 的 threadId 选项仅在 hook 线程为 null/undefined 时才生效)。
return [mergedThread, sendMessage, isUploading, onStreamThreadId] as const;
}
export function useThreads(
@@ -4,19 +4,27 @@
* 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签
* (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤,
* 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。
*
* 同一个 taskId 可能挂在多条业务链上(rwfx / 3qfx / xdfx 共用任务 id),每条业务用各自的
* 智能体。传入 ``agentId`` 时额外按 ``metadata.agent_id`` 过滤(后端 metadata 过滤是 AND/子集
* 匹配),把不同业务的对话列表隔离开——否则三种业务的对话会混在一起。
*/
import { useThreadsPage } from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
export function useTaskThreads(taskId: string | null | undefined) {
export function useTaskThreads(
taskId: string | null | undefined,
agentId?: string | null | undefined,
) {
const enabled = !!taskId;
// 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。
const query = useThreadsPage({
page: 1,
pageSize: 100,
metadata: enabled ? { taskId } : { taskId: "__none__" },
});
const metadata = enabled
? agentId
? { taskId, agent_id: agentId }
: { taskId }
: { taskId: "__none__" };
const query = useThreadsPage({ page: 1, pageSize: 100, metadata });
const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : [];
return {
...query,
+4
View File
@@ -11,6 +11,10 @@
* {@link DESENSITIZE_MAP}。
*/
export const SEED_DESENSITIZE_MAP: Readonly<Record<string, string>> = Object.freeze({
// 任务工作区业务名 → 业务码(脱敏展示)。整词在前,优先于下面更短的「任务」「行动」匹配。
任务分析: "rwfx",
三情分析: "3qfx",
行动分析: "xdfx",
方案: "FA",
设计: "SJ",
任务: "RW",
+54 -15
View File
@@ -37,6 +37,8 @@ export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
taskGate,
taskBind,
autoStartWhenPurposeEmpty,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
@@ -49,6 +51,11 @@ export default function AgentChatPage({
/** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答),
* 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */
taskGate?: boolean;
/** 3qfx / xdfx 任务深链:仅做任务↔对话 1:N 绑定(打 `metadata.taskId` 标签 + 切换会话停旧 run),
* 不展示目的树闸门;新建对话直接把任务开场信息自动发出。与 `taskGate` 互斥使用。 */
taskBind?: boolean;
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
autoStartWhenPurposeEmpty?: boolean;
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
@@ -63,15 +70,23 @@ export default function AgentChatPage({
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
// rwfx 任务闸门:读取登录深链/「新建对话」暂存的任务上下文(taskId + 开场信息)。
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
const taskCtxOn = !!(taskGate || taskBind);
const taskContext = useMemo(
() => (taskGate ? readTaskContext() : null),
[taskGate],
() => (taskCtxOn ? readTaskContext() : null),
[taskCtxOn],
);
const taskId =
taskGate && taskContext && taskContext.agentId === agent_id
taskCtxOn && taskContext && taskContext.agentId === agent_id
? taskContext.taskId
: "";
// 纯绑定(taskBind 且无闸门):新建对话自动发出的任务开场信息。rwfx 走闸门发出,不在此列。
const taskAutoSendText =
taskBind && !taskGate && taskContext && taskContext.agentId === agent_id
? (taskContext.openingText?.trim() ?? "")
: "";
// 纯绑定每次进入新建对话都自动发一次开场信息;ref 防止同一新建会话内重复发出。
const taskAutoSentRef = useRef(false);
// If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI
@@ -88,7 +103,7 @@ export default function AgentChatPage({
}
};
const [hasStartedConversation, setHasStartedConversation] = useState(
hasPendingForThisAgent,
() => hasPendingForThisAgent() || !!taskAutoSendText,
);
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
@@ -107,13 +122,15 @@ export default function AgentChatPage({
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(hasPendingForThisAgent());
setHasStartedConversation(hasPendingForThisAgent() || !!taskAutoSendText);
// 进入新建对话:允许纯绑定再次自动发出开场信息。
taskAutoSentRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
const [thread, sendMessage, , streamThreadId] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
@@ -162,20 +179,20 @@ export default function AgentChatPage({
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
// 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。
// 任务工作区(rwfx 闸门 + 3qfx/xdfx 纯绑定)启用——普通问答页保留后台续跑行为不变。
const prevTaskThreadRef = useRef<string | null>(null);
useEffect(() => {
if (!taskGate) return;
if (!taskCtxOn) return;
const prev = prevTaskThreadRef.current;
if (prev && prev !== threadId) void stopThreadRun(prev);
prevTaskThreadRef.current = threadId ?? null;
}, [taskGate, threadId]);
}, [taskCtxOn, threadId]);
useEffect(() => {
// 离开任务问答页(卸载)时也停掉当前会话的服务端 run。
return () => {
if (taskGate) void stopThreadRun(prevTaskThreadRef.current);
if (taskCtxOn) void stopThreadRun(prevTaskThreadRef.current);
};
}, [taskGate]);
}, [taskCtxOn]);
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
// 仅切到对话态(隐藏面板),让用户手动输入。
@@ -210,6 +227,22 @@ export default function AgentChatPage({
handleSubmit({ text: pending.text, files: [] });
}, [agent_id, threadId, isNewThread, handleSubmit]);
// 纯绑定任务深链(3qfx/xdfx,无闸门):进入新建对话即自动发出任务开场信息。
// 每次新建对话都带(与 rwfx 闸门「开始问答」发出等价);ref 防止同一会话内重复发出。
//
// 必须等 `streamThreadId == null`(底层 stream 已切到全新线程)才发:从已有会话点「新建对话」时,
// `isNewThread` 先翻 true,但 useThreadStream 的 onStreamThreadId 要慢一帧才重置为 null。
// 若在那一帧就 submit,hook 仍绑定上一个会话,submit 的 threadId 选项被忽略 → 开场信息发进
// 上一个对话(表现为「一直在同一个聊天框里发消息」)。等它归零再发即落到新建线程。
useEffect(() => {
if (!taskAutoSendText) return;
if (!isNewThread) return;
if (streamThreadId != null) return;
if (taskAutoSentRef.current) return;
taskAutoSentRef.current = true;
handleSubmit({ text: taskAutoSendText, files: [] });
}, [taskAutoSendText, isNewThread, streamThreadId, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
@@ -223,8 +256,9 @@ export default function AgentChatPage({
}, [thread]);
const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
const showTaskGate = !!taskId && isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
// 3qfx/xdfx(taskBind)不展示闸门,靠 taskAutoSendText 直接发出开场信息。
const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const status = thread.error
@@ -290,7 +324,11 @@ export default function AgentChatPage({
</header>
{showTaskGate && (
<TaskPurposePanel taskId={taskId} onStart={handleGateStart} />
<TaskPurposePanel
taskId={taskId}
onStart={handleGateStart}
autoStartWhenEmpty={autoStartWhenPurposeEmpty}
/>
)}
{isLandingState ? (
@@ -403,6 +441,7 @@ export default function AgentChatPage({
agentName={agentDisplayName}
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
defaultCollapsed={taskCtxOn}
/>
</div>
</ThreadContext.Provider>
+6 -2
View File
@@ -15,6 +15,7 @@ import {
import {
fetchCopTaskDetail,
persistCopTaskDetail,
resolveDeeplinkTaskId,
resolveTaskDeeplinkTarget,
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
@@ -160,11 +161,14 @@ export default function LoginPage() {
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
if (taskId && goPath) {
try {
const detail = await fetchCopTaskDetail(taskId);
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。
const realTaskId = await resolveDeeplinkTaskId(goPath, taskId);
if (cancelled) return;
const detail = await fetchCopTaskDetail(realTaskId);
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
+53 -23
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
@@ -12,6 +12,7 @@ import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyF
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import {
TaskBindChat,
TaskFxChat,
TaskFxIndexResolver,
TaskFxWorkspace,
@@ -39,6 +40,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";
@@ -59,20 +61,6 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
* 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage
* 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文,
* 侧边栏保持对应业务高亮)。
*/
function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} />
</ChatRuntime>
);
}
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
@@ -155,12 +143,26 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
{/* 三情分析:任务工作区(左对话列表 1:N,无目的树闸门;新建对话直接带开场信息)。 */}
<Route
path="plan-task-sentiment/agent/:agent_id/chats/:thread_id"
path="plan-task-sentiment/agent/:agent_id/chats"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task-sentiment" business="3qfx" lite />
}
/>
>
<Route
index
element={
<TaskFxIndexResolver taskBase="/page/strategy/task-management/plan-task-sentiment" />
}
/>
<Route
path=":thread_id"
element={
<TaskBindChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
}
/>
</Route>
<Route
path="plan-task/plan-task-fx"
element={
@@ -184,7 +186,7 @@ export default function StrategyRoutes() {
<Route
path="plan-task/plan-task-fx/agent/:agent_id/chats"
element={
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" business="rwfx" />
}
>
<Route
@@ -196,7 +198,10 @@ export default function StrategyRoutes() {
<Route
path=":thread_id"
element={
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
<TaskFxChat
taskBase="/page/strategy/task-management/plan-task/plan-task-fx"
autoStartWhenPurposeEmpty
/>
}
/>
</Route>
@@ -219,12 +224,27 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
{/* 行动分析:任务工作区(左对话列表 1:N 精简版;右侧带目的树闸门,与 rwfx 同一查询/显示)。
注意:xdfx 深链传入的是「行动id」,登录时已先经 resolveDeeplinkTaskId 换成任务id。 */}
<Route
path="plan-task/plan-task-action/agent/:agent_id/chats/:thread_id"
path="plan-task/plan-task-action/agent/:agent_id/chats"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-action" business="xdfx" lite />
}
/>
>
<Route
index
element={
<TaskFxIndexResolver taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
}
/>
<Route
path=":thread_id"
element={
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
}
/>
</Route>
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
@@ -362,6 +382,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/task-buttons"
element={
getStoredAuth()?.system_role === "admin" ? (
<TaskButtonsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
+375
View File
@@ -0,0 +1,375 @@
"use client";
/**
* 按钮管理(仅 admin):配置任务工作区(rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)
* 左侧栏的可配置跳转按钮——改名称、改跳转方式与地址、启停、排序、增删。
*
* 全局共享、整体替换保存(``PUT /api/task-buttons``)。后端整张表为空时回退代码种子默认
* (rwfx 的 4 个历史按钮),此页首次进入也以种子回填工作副本,便于从当前行为起步。
* 见 [[task-buttons]]。
*/
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { getStoredAuth } from "@/core/auth";
import {
DEFAULT_TASK_DEEPLINK_CONFIG,
fetchTaskDeeplinkConfig,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
TASK_BUSINESSES,
TASK_BUSINESS_TARGETS,
defaultTaskButtons,
type TaskBusiness,
type TaskButton,
} from "@/core/auth/task-buttons";
import { useTaskButtonActions, useTaskButtons } from "@/strategy-components/api/task-buttons";
let _seq = 1;
const newId = (b: TaskBusiness) => `${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<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
useEffect(() => {
let alive = true;
void fetchTaskDeeplinkConfig().then((c) => {
if (alive) setCfg(c);
});
return () => {
alive = false;
};
}, []);
const [rows, setRows] = useState<TaskButton[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [tab, setTab] = useState<TaskBusiness>("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 <Navigate to="/page/workspace/chats/new" replace />;
const update = (id: string, patch: Partial<TaskButton>) =>
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 (
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
<div className="border-border/70 flex items-center justify-between gap-3 border-b px-5 py-4">
<div className="flex items-center gap-2">
<MousePointerClick className="h-5 w-5 text-[var(--color-main,#0062D9)]" />
<div>
<div className="text-foreground text-base font-semibold">按钮管理</div>
<div className="text-muted-foreground text-xs">
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => importDefaults()}>
<Download className="h-4 w-4" /> 导入全部默认
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending}>
<Save className="h-4 w-4" /> {save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
{/* 业务 tab:每个 tab = 一个任务深链页面,明确区分三种页面的按钮 */}
<div className="border-border/70 flex items-stretch gap-2 border-b px-5 pt-3">
{TASK_BUSINESSES.map((b) => {
const count = rows.filter((r) => r.business === b.code).length;
const active = tab === b.code;
return (
<button
key={b.code}
type="button"
onClick={() => setTab(b.code)}
className={cn(
"flex min-w-[8.5rem] flex-col gap-0.5 rounded-t-lg border border-b-0 px-4 py-2 text-left transition-colors",
active
? "border-[var(--color-main,#0062D9)] bg-[var(--color-main,#0062D9)] text-white"
: "border-border/70 text-muted-foreground hover:bg-muted bg-transparent",
)}
>
<span className="flex items-center gap-1.5 text-sm font-semibold">
{b.name}
<span
className={cn(
"rounded px-1.5 text-[11px] tabular-nums",
active ? "bg-white/20" : "bg-muted-foreground/15",
)}
>
{count}
</span>
</span>
<span className={cn("text-[11px]", active ? "text-white/80" : "opacity-70")}>
?goPath={b.goPath}
</span>
</button>
);
})}
</div>
{/* 当前页面说明 */}
{(() => {
const meta = TASK_BUSINESSES.find((b) => b.code === tab)!;
return (
<div className="border-border/70 bg-muted/40 text-muted-foreground border-b px-5 py-2 text-xs">
正在编辑「<span className="text-foreground font-medium">{meta.name}</span>
」页面的按钮 · {meta.desc} 下方按钮会显示在该页面底部的「快捷跳转」栏,从上到下对应栏内从左到右的顺序。
</div>
);
})()}
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="text-muted-foreground text-sm">加载中…</div>
) : tabRows.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-md border border-dashed p-6 text-center text-sm">
该页面暂无按钮。可「导入该页面默认按钮」一键填充,或点下方「添加按钮」自定义。
<Button variant="outline" size="sm" onClick={() => importDefaults(tab)}>
<Download className="h-4 w-4" /> 导入该页面默认按钮
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{tabRows.map((r, idx) => (
<div
key={r.id}
className="border-border/70 grid grid-cols-[1fr_auto] items-start gap-3 rounded-lg border bg-card/60 p-3"
>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<label className="text-muted-foreground w-16 shrink-0 text-xs">名称</label>
<Input
value={r.label}
onChange={(e) => update(r.id, { label: e.target.value })}
placeholder="按钮名称"
className="h-8 max-w-xs"
/>
<label className="text-muted-foreground ml-2 shrink-0 text-xs">类型</label>
<Select
value={r.linkType}
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
>
<SelectTrigger className="h-8 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url">外部链接</SelectItem>
<SelectItem value="business">切换业务</SelectItem>
<SelectItem value="purpose">目的树弹框</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="text-muted-foreground w-16 shrink-0 text-xs">目标</label>
{r.linkType === "purpose" ? (
<span className="text-muted-foreground text-xs">
点击弹出当前任务的「任务目的」表格,无需配置目标。
</span>
) : r.linkType === "url" ? (
<>
<Input
value={r.target}
onChange={(e) => update(r.id, { target: e.target.value })}
placeholder="https://…(跳转地址)"
className="h-8 flex-1 min-w-[16rem]"
/>
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
<Switch
checked={r.appendTaskId}
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
/>
追加参数
</label>
{r.appendTaskId && (
<div className="flex shrink-0 items-center gap-1 text-xs">
<span className="text-muted-foreground">?</span>
<Input
value={r.idParam}
onChange={(e) => update(r.id, { idParam: e.target.value })}
placeholder="id"
className="h-8 w-24"
title="查询参数名(如 id / task_id)"
/>
<span className="text-muted-foreground">=</span>
{/* 行动分析(xdfx):行动id 与任务id 不同,可选追加哪种;其它业务固定任务id */}
{tab === "xdfx" ? (
<Select
value={r.idKind}
onValueChange={(v) => update(r.id, { idKind: v as TaskButton["idKind"] })}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">任务id</SelectItem>
<SelectItem value="action">行动id</SelectItem>
</SelectContent>
</Select>
) : (
<span className="text-muted-foreground">任务id</span>
)}
</div>
)}
</>
) : (
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
<SelectTrigger className="h-8 w-48">
<SelectValue placeholder="选择目标业务" />
</SelectTrigger>
<SelectContent>
{TASK_BUSINESS_TARGETS.map((t) => (
<SelectItem key={t.code} value={t.code}>
{t.label}({t.code})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="flex items-center gap-1">
<label className="text-muted-foreground mr-1 flex items-center gap-1.5 text-xs">
<Switch
checked={r.enabled}
onCheckedChange={(v) => update(r.id, { enabled: v })}
/>
启用
</label>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={idx === 0}
onClick={() => moveRow(r.id, "up")}
title="上移"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={idx === tabRows.length - 1}
onClick={() => moveRow(r.id, "down")}
title="下移"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeRow(r.id)}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
<Button variant="outline" size="sm" className="mt-4" onClick={addRow}>
<Plus className="h-4 w-4" /> 添加按钮
</Button>
</div>
</div>
);
}
@@ -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<T>(res: Response, ctx: string): Promise<T> {
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<TaskButton[]> {
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<TaskButton[]> {
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] }),
}),
};
}
@@ -1,20 +1,22 @@
/**
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
* 任务深链的「任务工作区」:一个任务挂多个对话(1:N)。三种业务共用本布局:
* - rwfx(任务FX,完整版):左对话列表 + 任务操作(编辑/下一步/清除记录)+ 跳转三情分析按钮;
* 右侧问答带「目的树闸门」(AgentChatPage taskGate)。
* - 3qfx(三情分析)/ xdfx(行动分析)(``lite`` 精简版):只有左对话列表(按 taskId + agentId 隔离),
* 无任务操作、无闸门;新建对话直接带任务开场信息自动发出(AgentChatPage taskBind)。
*
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
* 右下角 = 常驻浮动操作条(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
* 左侧顶部 = 当前任务卡片(任务名称 + 简介,来自登录深链暂存的 copTaskDetail)+「新建对话」;
* 右侧 = <Outlet/> 渲染选中的对话。
*
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
* - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
* 见 [[task-purpose]] / [[task-deeplink-config]]。
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。
* - 切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
* - (rwfx)清除任务记录:仅调 delete 接口(聊天对话保留);编辑/下一步:window.open B 系统地址。
* 见 [[task-purpose]] / [[task-deeplink]] / [[task-deeplink-config]]。
*/
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";
@@ -26,24 +28,66 @@ 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 { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
DEFAULT_TASK_DEEPLINK_CONFIG,
fetchTaskDeeplinkConfig,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
TASK_BUSINESSES,
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; chatsBase: string } {
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
const { agent_id } = useParams<{ agent_id: string }>();
const agentId = agent_id ?? "";
const taskId = readTaskContext()?.taskId ?? "";
return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
return { taskId, agentId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
}
/**
* 左侧顶部「当前任务」卡片:展示登录深链查询出的任务名称 + 简介(留存在 sessionStorage 的
* copTaskDetail)。三种业务工作区都展示,放在「新建对话」上面。无任务详情时不渲染。
*/
function TaskDetailHeader() {
const detail = useMemo(() => readCopTaskDetail(), []);
const name = desensitize((detail?.taskName ?? "").trim());
const brief = desensitize(((detail?.overview ?? "").trim() || (detail?.taskContent ?? "").trim()));
if (!name && !brief) return null;
return (
<div className="mb-3 rounded-md border border-[var(--card-border)] bg-background/70 p-2.5 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0b2546]/70">
<div className="text-muted-foreground mb-1 text-[11px] font-medium">当前任务</div>
{name && (
<div
className="text-foreground line-clamp-2 text-sm font-semibold leading-snug"
title={name}
>
{name}
</div>
)}
{brief && (
<div
className="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed"
title={brief}
>
{brief}
</div>
)}
</div>
);
}
/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
@@ -53,16 +97,111 @@ function parseThreadId(pathname: string, chatsBase: string): string | null {
return seg && seg !== "new" ? seg : null;
}
/** 左侧任务对话列表 +「新建对话」。 */
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
/**
* 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。
* 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。
*/
function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) {
const navigate = useNavigate();
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => 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);
// xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。
const ctxActionId = readTaskContext()?.actionId;
const purposeActionId =
business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined;
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 (
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
{jumpButtons.map((btn) => (
<Button
key={btn.id}
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
onClick={() => handleRun(btn)}
title={desensitize(btn.label)}
>
<span className="truncate">{desensitize(btn.label)}</span>
{btn.linkType === "url" ? (
<ExternalLink className="h-5 w-5 shrink-0" />
) : btn.linkType === "purpose" ? (
<Maximize2 className="h-5 w-5 shrink-0" />
) : (
<ArrowRight className="h-5 w-5 shrink-0" />
)}
</Button>
))}
<TaskPurposeDialog
taskId={taskId}
open={purposeOpen}
onOpenChange={setPurposeOpen}
resolveFromActionId={purposeActionId}
/>
</div>
);
}
/**
* 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。
* 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。
*/
function TaskConversationsList({
taskId,
agentId,
chatsBase,
business,
lite,
}: {
taskId: string;
agentId: string;
chatsBase: string;
business: TaskBusiness;
lite?: boolean;
}) {
const { pathname } = useLocation();
const navigate = useNavigate();
const { threads, isLoading } = useTaskThreads(taskId);
const { threads, isLoading } = useTaskThreads(taskId, agentId);
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
const businessName = desensitize(TASK_BUSINESSES.find((b) => b.code === business)?.name ?? "");
return (
<div className="bg-background/85 border-border/70 flex w-64 shrink-0 flex-col border-r shadow-[12px_0_30px_-30px_rgba(15,23,42,0.45)] backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/82">
<div className="border-border/70 border-b p-3 dark:border-[#1b477e]/70">
{/* 左上角标明当前是哪个分析页面 */}
<div className="mb-3 flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-[var(--color-main)]" />
<span className="text-foreground text-sm font-semibold tracking-wide">{businessName}</span>
</div>
<TaskDetailHeader />
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-foreground text-sm font-semibold">任务对话</div>
@@ -78,6 +217,7 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
{!lite && <TaskClearButton taskId={taskId} />}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
@@ -114,24 +254,18 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
))
)}
</div>
<TaskActionsPanel taskId={taskId} />
</div>
);
}
/**
* 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。
* 跟随任务对话面板布局,不再覆盖右侧聊天输入框。
* 「清除记录」按钮(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 {
@@ -148,44 +282,16 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
return (
<>
<div className="border-border/70 shrink-0 border-t p-3 dark:border-[#1b477e]/70">
<div className="rounded-md border border-[var(--card-border)] bg-background/80 p-2 shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#0b2546]/72">
<div className="text-muted-foreground mb-2 px-1 text-xs font-medium">
任务操作
</div>
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="h-8 border-[var(--card-border)] bg-background/80 text-[var(--color-main)] hover:bg-[var(--bg-color)] hover:text-[var(--color-main)] dark:border-[#2b8ffe]/50 dark:bg-[#0b2546]/80 dark:text-[#bde9ff] dark:hover:bg-[#10375f]"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
disabled={!taskId}
title="编辑任务"
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
className="h-8 bg-[var(--color-main)] text-white shadow-[0_8px_18px_-12px_var(--color-main)] hover:bg-[var(--brand-950)]"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
disabled={!taskId}
title="进入下一步"
>
下一步 <ArrowRight className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="col-span-2 h-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35"
onClick={() => setClearOpen(true)}
disabled={!taskId}
title="清除任务记录"
>
<Trash2 className="h-3.5 w-3.5" /> 清除记录
</Button>
</div>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35 mt-2 h-9 w-full"
onClick={() => setClearOpen(true)}
disabled={!taskId}
title="清除任务记录"
>
<Trash2 className="h-3.5 w-3.5" /> 清除记录
</Button>
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
<DialogContent>
@@ -209,28 +315,44 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
);
}
/** 任务工作区布局:左列表 + 右 Outlet。 */
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase);
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
useEffect(() => {
void fetchTaskDeeplinkConfig();
}, []);
/**
* 任务工作区布局:左列表 + 右 Outlet。
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
*/
export function TaskFxWorkspace({
taskBase,
business,
lite,
}: {
taskBase: string;
business: TaskBusiness;
lite?: boolean;
}) {
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
return (
<div className="flex size-full min-h-0 overflow-hidden">
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden">
<TaskConversationsList
taskId={taskId}
agentId={agentId}
chatsBase={chatsBase}
business={business}
lite={lite}
/>
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
</div>
</div>
<TaskJumpBar business={business} taskId={taskId} />
</div>
);
}
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase);
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
const navigate = useNavigate();
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId);
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId);
useEffect(() => {
if (isLoading) return;
@@ -248,12 +370,35 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
);
}
/** 右侧对话宿主: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 (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskGate />
<AgentChatPage
chatBasePath={`${taskBase}/agent/${agent_id}/chats`}
taskGate
autoStartWhenPurposeEmpty={autoStartWhenPurposeEmpty}
/>
</ChatRuntime>
);
}
/** 右侧对话宿主(3qfx/xdfx):AgentChatPage taskBind → 仅 1:N 绑定,新建对话直接带开场信息自动发出。 */
export function TaskBindChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskBind />
</ChatRuntime>
);
}
@@ -10,8 +10,8 @@
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
*/
import { useEffect, useMemo, useState } from "react";
import { 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";
@@ -30,10 +30,12 @@ import {
type PurposeTreeNode,
type TaskPurposeResult,
} from "@/core/auth/task-purpose";
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
import { desensitize } from "@/lib/desensitize";
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);
@@ -57,7 +59,7 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
key={i}
className="bg-muted/60 border-border/60 sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-nowrap"
>
{h}
{desensitize(h)}
</th>
))}
</tr>
@@ -71,9 +73,9 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
<td key={ci} className="border-border/60 border px-3 py-1.5">
{cell ? (
<>
<span>{cell.text || "(空)"}</span>
<span>{cell.text ? desensitize(cell.text) : "(空)"}</span>
{cell.reason ? (
<span className="text-muted-foreground block text-xs">理由:{cell.reason}</span>
<span className="text-muted-foreground block text-xs">理由:{desensitize(cell.reason)}</span>
) : null}
</>
) : (
@@ -90,26 +92,103 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
);
}
/**
* 独立的「任务目的弹框」:按需拉取目的树并放大展示。供底部「快捷跳转」栏的 ``purpose`` 类型
* 按钮复用(任意页面可配置「弹框查看」)。打开时才拉取。
*/
export function TaskPurposeDialog({
taskId,
open,
onOpenChange,
resolveFromActionId,
}: {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** xdfx:深链原始「行动id」。提供时弹框先用它换出真正「任务id」再查目的树。 */
resolveFromActionId?: string;
}) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<TaskPurposeResult | null>(null);
const [resolvedTaskId, setResolvedTaskId] = useState(taskId);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
(async () => {
try {
// xdfx:行动id 换任务id 再查目的树(与登录时的解析一致);无行动id 时直接用任务id。
let tid = taskId;
if (resolveFromActionId) {
tid = (await fetchTaskIdFromAction(resolveFromActionId)) || taskId;
}
if (cancelled) return;
setResolvedTaskId(tid);
const r = await fetchTaskPurposeDetail(tid);
if (!cancelled) setResult(r);
} catch {
if (!cancelled) setResult({ tree: [], hasData: false });
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, taskId, resolveFromActionId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] !max-w-[90vw] sm:!max-w-[90vw]">
<DialogHeader>
<DialogTitle>任务目的(任务 {resolvedTaskId})</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
)}
</DialogContent>
</Dialog>
);
}
export function TaskPurposePanel({
taskId,
onStart,
autoStartWhenEmpty,
}: {
taskId: string;
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
onStart: () => void;
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
autoStartWhenEmpty?: boolean;
}) {
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<TaskPurposeResult | null>(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 });
@@ -120,6 +199,8 @@ export function TaskPurposePanel({
return () => {
cancelled = true;
};
// onStart/autoStartWhenEmpty 在一次新建会话内稳定;仅按 taskId 重新拉取。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
const handleStart = () => {
@@ -151,7 +232,19 @@ export function TaskPurposePanel({
return (
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">任务目的(任务 {taskId})</span>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--color-main)]"
title={collapsed ? "展开任务目的" : "收起任务目的"}
>
{collapsed ? (
<ChevronRight className="h-4 w-4 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 shrink-0" />
)}
任务目的(任务 {taskId})
</button>
<div className="flex items-center gap-2">
{hasTree && (
<Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
@@ -164,13 +257,14 @@ export function TaskPurposePanel({
</div>
</div>
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
)}
{!collapsed &&
(loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
))}
{/* 弹框查看:放大显示完整表格 */}
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
@@ -256,6 +256,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **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`. |
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.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.
@@ -72,6 +72,7 @@ from app.gateway.routers import (
suggestions,
system_settings,
tags,
task_buttons,
thread_runs,
tool_metrics,
thread_shares,
@@ -1360,6 +1361,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
app.include_router(dashboard_sessions.router)
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
@@ -275,6 +275,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.dashboard_session_store = make_dashboard_session_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)
@@ -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])
+18 -1
View File
@@ -420,13 +420,18 @@ log_system:
label: '日志系统'
task_deeplink:
# 线上接口未通时用假数据;两个开关互相独立。
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
detail_3q_url: ""
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
@@ -455,3 +460,15 @@ task_deeplink:
delete:
message: "行动已经开始策划,不可覆盖"
success: true
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
action_detail:
state: "200"
msg: "操作成功!"
data:
id: 27354
purposeId: 2850
taskId: 2055
actionName: ""
actionStatus: 25
narration: ""
@@ -1,12 +1,14 @@
"""任务深链(``goPath=rwfx``)的「目的树闸门 + 跳转」配置。
"""任务深链(``goPath=rwfx`` / ``xdfx``)的「目的树闸门 + 行动→任务解析 + 跳转」配置。
前端(登录深链流程)通过公开只读接口 ``GET /api/public/config/task-deeplink``
读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=``),
读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=`` 或 ``/{actionId}`` 路径段),
随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。
测试开关(线上接口未通时用假数据,互相独立):
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。
- ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。
- ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。
xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。
"""
from __future__ import annotations
@@ -29,10 +31,22 @@ class TaskDeeplinkConfig(BaseModel):
default=False,
description="为真时前端删除接口走 mock.delete 假数据(删除接口未开发完时打开)",
)
action_test: bool = Field(
default=False,
description="为真时前端「行动→任务」解析走 mock.action_detail 假数据(不打真实接口)",
)
purpose_detail_url: str = Field(
default="",
description="目的树查询完整 URL,调用时追加 ?taskId=",
)
action_detail_url: str = Field(
default="",
description="行动详情接口基址(xdfx 行动→任务),调用时追加 /{actionId} 路径段;返回 data.taskId 为真正任务id",
)
detail_3q_url: str = Field(
default="",
description="「3q详情」详情页 URL(rwfx 目的树面板的链接),跳转时追加 ?id=(任务id)",
)
delete_url: str = Field(
default="",
description="删除完整 URL,调用时追加 ?taskId=",
@@ -47,5 +61,5 @@ class TaskDeeplinkConfig(BaseModel):
)
mock: dict[str, Any] = Field(
default_factory=dict,
description="test/delete_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}}",
description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}",
)
@@ -66,6 +66,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
@@ -123,6 +124,7 @@ __all__ = [
"SkillFavoriteRow",
"TagAssignmentRow",
"TagRow",
"TaskButtonRow",
"EmbedSessionRow",
"ThreadMetaRow",
"ThreadShareRow",
@@ -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)
@@ -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
@@ -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()]
@@ -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)
)
@@ -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]
@@ -34,6 +34,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
if item is None:
return None
record = dict(item.value)
# 任务深链对话(metadata.taskId)全局共享:跳过 owner 校验。
if (record.get("metadata") or {}).get("taskId"):
return record
if resolved is not None and record.get("user_id") != resolved:
return None
return record
@@ -78,6 +81,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
query: str | None = None,
) -> list[dict]:
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
# 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。
if metadata and metadata.get("taskId"):
resolved_user_id = None
filter_dict: dict[str, Any] = {}
if metadata:
filter_dict.update(metadata)
@@ -137,6 +143,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
) -> int:
"""Count threads matching the same filters as :meth:`search`."""
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.count")
# 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。
if metadata and metadata.get("taskId"):
resolved_user_id = None
filter_dict: dict[str, Any] = {}
if metadata:
filter_dict.update(metadata)
@@ -174,6 +183,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
item = await self._store.aget(THREADS_NS, thread_id)
if item is None:
return not require_existing
# 任务深链对话(metadata.taskId)全局共享、不区分用户:任何登录用户都可读写/续聊。
if (item.value.get("metadata") or {}).get("taskId"):
return True
record_user_id = item.value.get("user_id")
if record_user_id is None:
return True
@@ -97,6 +97,9 @@ class ThreadMetaRepository(ThreadMetaStore):
row = await session.get(ThreadMetaRow, thread_id)
if row is None:
return not require_existing
# 任务深链对话(metadata.taskId)全局共享、不区分用户:任何登录用户都可读写/续聊。
if (row.metadata_json or {}).get("taskId"):
return True
if row.user_id is None:
return True
return row.user_id == user_id
@@ -118,6 +121,9 @@ class ThreadMetaRepository(ThreadMetaStore):
context. Pass ``user_id=None`` to bypass (migration/CLI).
"""
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
# 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。
if metadata and metadata.get("taskId"):
resolved_user_id = None
# thread_id tie-breaker keeps OFFSET pagination stable when many rows
# share the same updated_at (bulk imports, scheduler bursts).
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
@@ -178,6 +184,9 @@ class ThreadMetaRepository(ThreadMetaStore):
) -> int:
"""Count threads matching the same filters as :meth:`search`."""
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.count")
# 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。
if metadata and metadata.get("taskId"):
resolved_user_id = None
conditions = []
if resolved_user_id is not None:
conditions.append(ThreadMetaRow.user_id == resolved_user_id)
@@ -224,7 +233,12 @@ class ThreadMetaRepository(ThreadMetaStore):
if resolved_user_id is None:
return True # explicit bypass
row = await session.get(ThreadMetaRow, thread_id)
return row is not None and row.user_id == resolved_user_id
if row is None:
return False
# 任务深链对话(metadata.taskId)全局共享:任何用户都可更新标题/状态/updated_at(续聊置顶)。
if (row.metadata_json or {}).get("taskId"):
return True
return row.user_id == resolved_user_id
async def update_display_name(
self,
@@ -287,7 +301,9 @@ class ThreadMetaRepository(ThreadMetaStore):
row = await session.get(ThreadMetaRow, thread_id)
if row is None:
return
if resolved_user_id is not None and row.user_id != resolved_user_id:
# 任务深链对话(metadata.taskId)全局共享:跳过 owner 校验。
task_shared = bool((row.metadata_json or {}).get("taskId"))
if resolved_user_id is not None and not task_shared and row.user_id != resolved_user_id:
return
merged = dict(row.metadata_json or {})
merged.update(metadata)
@@ -306,7 +322,9 @@ class ThreadMetaRepository(ThreadMetaStore):
row = await session.get(ThreadMetaRow, thread_id)
if row is None:
return
if resolved_user_id is not None and row.user_id != resolved_user_id:
# 任务深链对话(metadata.taskId)全局共享:任何用户都可删除。
task_shared = bool((row.metadata_json or {}).get("taskId"))
if resolved_user_id is not None and not task_shared and row.user_id != resolved_user_id:
return
await session.delete(row)
await session.commit()
+18 -1
View File
@@ -330,13 +330,18 @@ summarization:
# GET /api/public/config/task-deeplink 读取。地址都是完整 URL(调用时仅追加
# ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。
task_deeplink:
# 线上接口未通时用假数据;两个开关互相独立。
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
detail_3q_url: ""
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
@@ -365,3 +370,15 @@ task_deeplink:
delete:
message: "行动已经开始策划,不可覆盖"
success: true
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
action_detail:
state: "200"
msg: "操作成功!"
data:
id: 27354
purposeId: 2850
taskId: 2055
actionName: ""
actionStatus: 25
narration: ""