From a2bb8c80a2c745273d94b6ca1c4096293aa978fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 17 Jun 2026 16:41:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E7=9B=AE=E7=9A=84?= =?UTF-8?q?=E6=A0=91=E8=A1=A8=E6=A0=BC=E5=8C=96=E5=B1=95=E7=A4=BA=20+=20?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D=E5=81=9C=E6=AD=A2=E8=80=81?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=9C=8D=E5=8A=A1=E7=AB=AF=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task-purpose: normalizeNode 容错(未知 *Dto 兜底/文本字段改名兜底);新增 flattenPurposeTree 拍平目的树 - TaskPurposePanel: 目的树改表格展示(列数自适应),支持弹框放大查看 - threads/hooks: 新增 stopThreadRun,真正取消服务端 run(非仅断开本地 SSE) - AgentChatPage: taskGate 下切换/卸载会话时停掉老对话服务端 run - TaskFxWorkspace: 操作按钮(编辑/下一步/清除)改右下角常驻红色浮层 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/core/auth/task-purpose.ts | 66 ++++++-- frontend-web/src/core/threads/hooks.ts | 33 ++++ frontend-web/src/pages/AgentChatPage.tsx | 18 ++- .../task-components/TaskFxWorkspace.tsx | 141 ++++++++++-------- .../task-components/TaskPurposePanel.tsx | 121 +++++++++++---- 5 files changed, 273 insertions(+), 106 deletions(-) diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts index 5ba824ae..b1a0bc3a 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -48,28 +48,70 @@ const DTO_FIELD_MAP: Record = { narrationDto: { label: "叙事", textKey: "narration" }, }; +// 取节点主文本时跳过的字段(id/时间/标记/理由等非展示文本),保证容错。 +const SKIP_TEXT_KEY = /(^id$|Id$|Time$|Flag$|Type$|Level$|recReason|createBy|updateBy|^mid$)/i; + +/** 从一个 dto 里取展示文本:优先 preferredKey,否则取第一个像「内容」的字符串字段。 */ +function pickDtoText(dto: Record, preferredKey?: string): string { + if (preferredKey && typeof dto[preferredKey] === "string" && (dto[preferredKey] as string).trim()) { + return dto[preferredKey] as string; + } + for (const [k, v] of Object.entries(dto)) { + if (typeof v === "string" && v.trim() && !SKIP_TEXT_KEY.test(k)) return v; + } + return ""; +} + +/** + * 把一层节点归一化为 {label,text,reason,children}。**容错**: + * - 优先匹配已知 dto(目的/行为体/...);未知 ``*Dto`` 字段也兜底(标签取字段名去 Dto); + * - 文本字段改名也能取到(pickDtoText 兜底); + * - 完全不认识的结构仍递归 childrenList,避免整棵树丢失。 + */ function normalizeNode(raw: unknown): PurposeTreeNode | null { if (!raw || typeof raw !== "object") return null; const obj = raw as Record; - // 找出本节点携带的 dto 字段(目的/行为体/...)。 - const dtoKey = Object.keys(DTO_FIELD_MAP).find( - (k) => obj[k] && typeof obj[k] === "object", - ); - if (!dtoKey) return null; - const { label, textKey } = DTO_FIELD_MAP[dtoKey]; - const dto = obj[dtoKey] as Record; - const text = typeof dto[textKey] === "string" ? (dto[textKey] as string) : ""; - const reason = - typeof dto.recReason === "string" && dto.recReason.trim() - ? (dto.recReason as string) - : undefined; + const isObj = (k: string) => obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k]); + // 先找已知 dto,再兜底任意 `*Dto` 字段。 + let dtoKey = Object.keys(DTO_FIELD_MAP).find(isObj); + if (!dtoKey) dtoKey = Object.keys(obj).find((k) => /Dto$/.test(k) && isObj(k)); + const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : []; const children = childrenRaw .map(normalizeNode) .filter((n): n is PurposeTreeNode => n !== null); + + if (!dtoKey) { + // 不认识的结构:仅当有子节点时保留为占位节点,否则丢弃。 + return children.length ? { label: "节点", text: "", children } : null; + } + + const mapped = DTO_FIELD_MAP[dtoKey]; + const dto = obj[dtoKey] as Record; + const label = mapped?.label ?? dtoKey.replace(/Dto$/, ""); + const text = pickDtoText(dto, mapped?.textKey); + const reason = + typeof dto.recReason === "string" && (dto.recReason as string).trim() + ? (dto.recReason as string) + : undefined; return { label, text, reason, children }; } +/** 表格行:根→叶一条路径上的各层单元格(层级数可变,做了容错)。 */ +export type PurposeFlatCell = { label: string; text: string; reason?: string }; + +/** 把目的树拍平成「一行一条叶子路径」,供表格展示(层级缺失/变动都不崩)。 */ +export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][] { + const rows: PurposeFlatCell[][] = []; + const walk = (node: PurposeTreeNode, trail: PurposeFlatCell[]) => { + const next = [...trail, { label: node.label, text: node.text, reason: node.reason }]; + if (!node.children || node.children.length === 0) rows.push(next); + else node.children.forEach((c) => walk(c, next)); + }; + nodes.forEach((n) => walk(n, [])); + return rows; +} + /** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */ function bodyToTree(body: unknown): PurposeTreeNode[] { const data = diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 0cee3331..6d34117b 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -121,6 +121,39 @@ function getRunMetadataStorage(): { }; } +/** + * 显式停止某会话「服务端正在跑」的 run。 + * + * 注意:useStream / `thread.stop()` 只中断本地 SSE 监听,服务端 run 仍会继续跑。 + * 任务流程要求「开启新对话时老对话要停止」,所以这里读出 SDK 存在 sessionStorage + * (``lg:stream:{threadId}``)里的 run id,调用 ``runs.cancel`` 真正取消后端 run, + * 再清掉本地 reconnect 记录。已结束 / 不存在的 run 取消失败时静默忽略。 + */ +export async function stopThreadRun( + threadId: string | null | undefined, +): Promise { + if (!threadId || typeof window === "undefined") return; + let runId: string | null = null; + try { + runId = normalizeStoredRunId( + window.sessionStorage.getItem(`lg:stream:${threadId}`), + ); + } catch { + runId = null; + } + if (!runId) return; + try { + await getAPIClient().runs.cancel(threadId, runId); + } catch { + /* 已结束 / 不存在 / 网络错误等忽略 */ + } + try { + window.sessionStorage.removeItem(`lg:stream:${threadId}`); + } catch { + /* noop */ + } +} + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index eaad5512..2d7372cd 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -26,7 +26,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { saveLastAgentId, useThreadSettings } from "@/core/settings"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { readTaskContext } from "@/core/auth/task-purpose"; @@ -161,6 +161,22 @@ export default function AgentChatPage({ [sendMessage, threadId, agent_id], ); + // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 + // 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。 + const prevTaskThreadRef = useRef(null); + useEffect(() => { + if (!taskGate) return; + const prev = prevTaskThreadRef.current; + if (prev && prev !== threadId) void stopThreadRun(prev); + prevTaskThreadRef.current = threadId ?? null; + }, [taskGate, threadId]); + useEffect(() => { + // 离开任务问答页(卸载)时也停掉当前会话的服务端 run。 + return () => { + if (taskGate) void stopThreadRun(prevTaskThreadRef.current); + }; + }, [taskGate]); + // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 // 仅切到对话态(隐藏面板),让用户手动输入。 const handleGateStart = useCallback(() => { 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 7eaa3e10..e251a68b 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -1,19 +1,19 @@ /** * 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。 * - * 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+ 任务级操作 - * (新建对话 / 编辑 / 下一步 / 清除任务记录); + * 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」; + * 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走; * 右侧 = 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。 * * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。 - * - 新建对话 = 直接跳入:navigate 到 chats/new,AgentChatPage 闸门态展示目的树面板。 + * - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。 * - 清除任务记录:仅调 delete 接口(聊天对话保留)。 * - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。 * 见 [[task-purpose]] / [[task-deeplink-config]]。 */ -import { useEffect, useState } from "react"; -import { Outlet, useNavigate, useParams } from "react-router-dom"; +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 { toast } from "sonner"; @@ -46,11 +46,59 @@ function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } { return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` }; } -/** 左侧任务对话列表 + 任务级操作。 */ +/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */ +function parseThreadId(pathname: string, chatsBase: string): string | null { + if (!pathname.startsWith(chatsBase)) return null; + const seg = pathname.slice(chatsBase.length).replace(/^\/+/, "").split("/")[0]; + return seg && seg !== "new" ? seg : null; +} + +/** 左侧任务对话列表 +「新建对话」。 */ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) { - const { thread_id } = useParams<{ thread_id: string }>(); + const { pathname } = useLocation(); const navigate = useNavigate(); const { threads, isLoading } = useTaskThreads(taskId); + const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]); + + return ( +
+
+ +
+ +
+ {isLoading ? ( +
加载中…
+ ) : threads.length === 0 ? ( +
暂无对话
+ ) : ( + threads.map((thread) => ( + + )) + )} +
+
+ ); +} + +/** + * 右下角常驻红色操作浮层:编辑 / 下一步 / 清除任务记录。 + * absolute 定位在工作区右侧面板内,z-40,时刻可见、不被聊天内容挤走。 + */ +function TaskFloatingActions({ taskId }: { taskId: string }) { const [clearOpen, setClearOpen] = useState(false); const [clearing, setClearing] = useState(false); @@ -74,33 +122,28 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas }; return ( -
-
- -
- - -
+ <> +
+ +
-
- {isLoading ? ( -
加载中…
- ) : threads.length === 0 ? ( -
暂无对话
- ) : ( - threads.map((thread) => ( - - )) - )} -
- !clearing && setClearOpen(o)}> @@ -143,17 +163,17 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas - -
+ ); } -/** 任务工作区布局:左列表 + 右 Outlet。 */ +/** 任务工作区布局:左列表 + 右 Outlet + 右下角常驻红色操作浮层。 */ export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { const { taskId, chatsBase } = useChatsBase(taskBase); // 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。 @@ -163,8 +183,9 @@ export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { return (
-
+
+
); @@ -192,7 +213,7 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) { ); } -/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板)。 */ +/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */ export function TaskFxChat({ taskBase }: { taskBase: string }) { const { agent_id } = useParams<{ agent_id: string }>(); return ( diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index aaea6f2a..c0998568 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -1,15 +1,17 @@ /** * 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。 * - * 流程:拉目的树并展示 → 点「开始问答」: + * 展示:目的树拍平成表格(一行一条「目的→行为体→预期→因素→叙事」路径),列数随数据自适应, + * 字段/层级变动都不崩(见 flattenPurposeTree / normalizeNode 的容错)。可点「弹框查看」放大。 + * 流程:点「开始问答」: * - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart(); * success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。 * - 无数据 → 直接 onStart()。 * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。 */ -import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Loader2, Maximize2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -24,28 +26,67 @@ import { import { deleteTaskPurpose, fetchTaskPurposeDetail, + flattenPurposeTree, type PurposeTreeNode, type TaskPurposeResult, } from "@/core/auth/task-purpose"; +import { cn } from "@/lib/utils"; + +/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */ +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); + return Array.from({ length: maxDepth }, (_, i) => { + const r = rows.find((row) => row[i]); + return r?.[i]?.label ?? `层级${i + 1}`; + }); + }, [rows]); + + if (rows.length === 0) { + return
暂无目的数据,可直接开始问答。
; + } -function PurposeNodes({ nodes, depth = 0 }: { nodes: PurposeTreeNode[]; depth?: number }) { return ( -
    - {nodes.map((node, i) => ( -
  • - - {node.label} - - {node.text || "(空)"} - {node.reason ? ( - 理由:{node.reason} - ) : null} - {node.children.length > 0 ? ( - - ) : null} -
  • - ))} -
+
+ + + + {headers.map((h, i) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {headers.map((_, ci) => { + const cell = row[ci]; + return ( + + ); + })} + + ))} + +
+ {h} +
+ {cell ? ( + <> + {cell.text || "(空)"} + {cell.reason ? ( + 理由:{cell.reason} + ) : null} + + ) : ( + — + )} +
+
); } @@ -61,6 +102,7 @@ export function TaskPurposePanel({ const [result, setResult] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [working, setWorking] = useState(false); + const [zoomOpen, setZoomOpen] = useState(false); useEffect(() => { let cancelled = false; @@ -81,11 +123,8 @@ export function TaskPurposePanel({ }, [taskId]); const handleStart = () => { - if (result?.hasData) { - setConfirmOpen(true); // 有数据 → 先确认覆盖 - } else { - onStart(); // 无数据 → 直接开始 - } + if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖 + else onStart(); // 无数据 → 直接开始 }; const handleConfirmOverwrite = async () => { @@ -107,27 +146,43 @@ export function TaskPurposePanel({ } }; + const hasTree = !!result && result.tree.length > 0; + return (
任务目的(任务 {taskId}) - +
+ {hasTree && ( + + )} + +
{loading ? (
正在查询任务目的…
- ) : result && result.tree.length > 0 ? ( -
- -
) : ( -
暂无目的数据,可直接开始问答。
+ )} + {/* 弹框查看:放大显示完整表格 */} + + + + 任务目的(任务 {taskId}) + + + + + + {/* 覆盖确认 */} !working && setConfirmOpen(o)}>