From 23d170cde1e2ca893a2fae4707e726b46bc3ac53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 23 Jun 2026 16:47:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(rwfx):=203q=20=E6=97=A0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E9=BB=98=E8=AE=A4=E5=8F=91=E9=80=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E4=BB=85=E3=80=8C=E4=B8=8D=E5=8E=BB3q?= =?UTF-8?q?=E5=88=86=E6=9E=90=E3=80=8D=E6=89=8D=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:rwfx 进入时的 3q 预检弹框与「目的树为空自动发送第一次问题」相互独立, 导致 3q 无数据时弹框出现的同时问题已被自动发出。 修复:新增 rwfx 专用 3q 门控(rwfx-sq-gate.tsx,RwfxSqGateProvider + useRwfxSqGate), 把自动发送交给门控—— - 3q 完成/无法判定 → 立即发送; - 3q 未完成 → 弹「前往 3qfx」框并挂起发送,仅当用户「暂不(不去3q分析)」或关闭弹框才发送; 点「前往 3qfx」则跳转并丢弃发送。 TaskPurposePanel 自动开始改调 gateStart(onStart);手动「开始问答」/覆盖确认不受影响。 替换原独立的 RwfxSqReportGate(它弹同一个框但没门控自动发送)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- .../task-components/TaskFxWorkspace.tsx | 89 +-------- .../task-components/TaskPurposePanel.tsx | 6 +- .../task-components/rwfx-sq-gate.tsx | 188 ++++++++++++++++++ 4 files changed, 202 insertions(+), 83 deletions(-) create mode 100644 frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 63365caf..7d727829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) -- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。 - **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「B1-CM助手-…」(**禁内部框架名**)。埋点集中在各 API client 的成功路径:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs}.ts`、`core/{agents,scheduled-tasks,knowledge}/api.ts`、`core/api/markdown-docx-export.ts`,以及问答完成钩子(`ChatPage`/`AgentChatPage` 的 `useThreadStream` `onFinish`,用最终汇总文本作 `returnParam`,optType 12)。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。 diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index ebee077c..93effc72 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -33,10 +33,8 @@ import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; import { fetchActionDetail, readCopTaskDetail, - resolveTaskDeeplinkTarget, type ActionDetail, } from "@/core/auth/task-deeplink"; -import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons"; import { useTaskThreads } from "@/core/threads/use-task-threads"; import { titleOfThread } from "@/core/threads/utils"; @@ -45,6 +43,7 @@ import { cn } from "@/lib/utils"; import AgentChatPage from "@/pages/AgentChatPage"; import { ChatRuntime } from "@/pages/WorkspaceRoutes"; import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar"; +import { RwfxSqGateProvider } from "@/strategy-components/components/task-components/rwfx-sq-gate"; function useChatsBase(taskBase: string): { taskId: string; @@ -293,82 +292,6 @@ function TaskClearButton({ taskId }: { taskId: string }) { ); } -/** - * rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口 - * (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空 - * (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」, - * 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。 - * 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。 - */ -function RwfxSqReportGate({ taskId }: { taskId: string }) { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - const [jumping, setJumping] = useState(false); - - useEffect(() => { - if (!taskId) return; - let alive = true; - void fetchSqReportStatus(taskId).then((status) => { - // 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。 - if (alive && status === "incomplete") setOpen(true); - }); - return () => { - alive = false; - }; - }, [taskId]); - - const handleGo3qfx = async () => { - const detail = readCopTaskDetail(); - if (!detail || !taskId) { - toast.error("缺少任务信息,无法跳转"); - return; - } - setJumping(true); - try { - // 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。 - const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); - if (target) { - setOpen(false); - navigate(target.path, { state: target.state }); - } else { - toast.error("未配置三情分析(3qfx)对应的业务智能体"); - } - } catch { - toast.error("跳转失败,请稍后再试"); - } finally { - setJumping(false); - } - }; - - return ( - !jumping && setOpen(o)}> - - - 3q 未完成 - - 未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)? - - - - - - - - - ); -} - /** * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 @@ -383,7 +306,7 @@ export function TaskFxWorkspace({ lite?: boolean; }) { const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase); - return ( + const content = (
- {/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */} - {business === "rwfx" && }
); + // rwfx 专属:3q 预检门控。进入时若 3q 未完成弹「前往 3qfx」框;并门控目的树为空时的 + // 自动发送——3q 无数据时不默认发送,仅当用户点「暂不(不去 3q 分析)」才发送。 + if (business === "rwfx") { + return {content}; + } + return content; } /** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */ diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index e1b052b5..61c494c5 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -31,6 +31,7 @@ import { type TaskPurposeResult, } from "@/core/auth/task-purpose"; import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink"; +import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -176,6 +177,8 @@ export function TaskPurposePanel({ const [collapsed, setCollapsed] = useState(true); // rwfx 空数据自动开始:防止重复触发。 const autoStartedRef = useRef(false); + // rwfx 3q 预检门控:3q 无数据时不默认发送,交由门控(仅「暂不」才发送)。 + const { gateStart } = useRwfxSqGate(); useEffect(() => { let cancelled = false; @@ -185,9 +188,10 @@ export function TaskPurposePanel({ if (cancelled) return; setResult(r); // 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。 + // 经 3q 门控:3q 完成/未知立即发送;3q 未完成则挂起,仅当用户「暂不(不去 3q 分析)」才发送。 if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) { autoStartedRef.current = true; - onStart(); + gateStart(onStart); } }) .catch(() => { diff --git a/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx b/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx new file mode 100644 index 00000000..2059dead --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx @@ -0,0 +1,188 @@ +/** + * rwfx(任务分析)专用「3q 预检门控」。 + * + * 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成: + * - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。 + * - ``complete`` / ``unknown`` → 不打扰。 + * + * 关键点:rwfx「目的树为空时自动发送第一次问题」必须**经本门控**(``gateStart``),不能在 3q + * 无数据时还默认发送: + * - 3q 完成 / 无法判定 → 立即发送; + * - 3q 未完成 → 挂起,**仅当用户点「暂不」(不去 3q 分析)才发送**;点「前往 3qfx」则跳转、丢弃发送。 + * + * Provider 由 ``TaskFxWorkspace``(仅 rwfx)包裹整个工作区;``TaskPurposePanel`` 经 ``useRwfxSqGate`` + * 读取门控。非 rwfx / 无 Provider → ``gateStart`` 直接发送(默认值)。见 [[dashboard-input]] / [[task-deeplink]]。 + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useNavigate } from "react-router-dom"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; +import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink"; + +type GateStatus = "loading" | "incomplete" | "ok"; + +interface RwfxSqGateValue { + /** + * rwfx 自动开始第一次问答的门控。 + * - 3q 完成 / 无法判定 → 立即 ``start()``。 + * - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。 + * 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。 + */ + gateStart: (start: () => void) => void; +} + +const RwfxSqGateContext = createContext({ + gateStart: (start) => start(), +}); + +export function useRwfxSqGate(): RwfxSqGateValue { + return useContext(RwfxSqGateContext); +} + +export function RwfxSqGateProvider({ + taskId, + children, +}: { + taskId: string; + children: ReactNode; +}) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [jumping, setJumping] = useState(false); + + const statusRef = useRef(taskId ? "loading" : "ok"); + // 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。 + const pendingStartRef = useRef<(() => void) | null>(null); + + // taskId 变化:重置门控状态并预检 3q。 + useEffect(() => { + pendingStartRef.current = null; + setOpen(false); + if (!taskId) { + statusRef.current = "ok"; + return; + } + statusRef.current = "loading"; + let alive = true; + void fetchSqReportStatus(taskId).then((s) => { + if (!alive) return; + // complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。 + const next: GateStatus = s === "incomplete" ? "incomplete" : "ok"; + statusRef.current = next; + if (next === "incomplete") { + setOpen(true); // 进入即提醒(无论是否有挂起的自动发送) + } else if (pendingStartRef.current) { + // 加载期间已请求自动开始 → 放行后补发。 + const start = pendingStartRef.current; + pendingStartRef.current = null; + start(); + } + }); + return () => { + alive = false; + }; + }, [taskId]); + + const gateStart = useCallback((start: () => void) => { + const status = statusRef.current; + if (status === "ok") { + start(); + return; + } + // loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。 + pendingStartRef.current = start; + if (status === "incomplete") setOpen(true); + }, []); + + /** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */ + const stayAndStart = useCallback(() => { + setOpen(false); + const start = pendingStartRef.current; + pendingStartRef.current = null; + start?.(); + }, []); + + const handleGo3qfx = async () => { + const detail = readCopTaskDetail(); + if (!detail || !taskId) { + toast.error("缺少任务信息,无法跳转"); + return; + } + setJumping(true); + try { + // 复用深链解析:清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。 + const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); + if (target) { + pendingStartRef.current = null; // 去 3q 分析 → 丢弃自动发送 + setOpen(false); + navigate(target.path, { state: target.state }); + } else { + toast.error("未配置三情分析(3qfx)对应的业务智能体"); + } + } catch { + toast.error("跳转失败,请稍后再试"); + } finally { + setJumping(false); + } + }; + + const value = useMemo(() => ({ gateStart }), [gateStart]); + + return ( + + {children} + { + if (jumping) return; + if (o) setOpen(true); + else stayAndStart(); // 关闭(暂不/ESC/点外部)= 不去 3q 分析 → 发送 + }} + > + + + 3q 未完成 + + 未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)? + + + + + + + + + + ); +}