feat: 任务目的树表格化展示 + 切换会话停止老对话服务端 run

- task-purpose: normalizeNode 容错(未知 *Dto 兜底/文本字段改名兜底);新增 flattenPurposeTree 拍平目的树
- TaskPurposePanel: 目的树改表格展示(列数自适应),支持弹框放大查看
- threads/hooks: 新增 stopThreadRun,真正取消服务端 run(非仅断开本地 SSE)
- AgentChatPage: taskGate 下切换/卸载会话时停掉老对话服务端 run
- TaskFxWorkspace: 操作按钮(编辑/下一步/清除)改右下角常驻红色浮层

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-17 16:42:05 +08:00
1 parent ba0ec20532
commit a2bb8c80a2
5 files changed
+273 -106

No files matched your search

+54 -12
View File
@@ -48,28 +48,70 @@ const DTO_FIELD_MAP: Record<string, { label: string; textKey: string }> = {
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<string, unknown>, 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<string, unknown>;
// 找出本节点携带的 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<string, unknown>;
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<string, unknown>;
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 =
+33
View File
@@ -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<void> {
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;
+17 -1
View File
@@ -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<string | null>(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(() => {
@@ -1,19 +1,19 @@
/**
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
*
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+ 任务级操作
* (新建对话 / 编辑 / 下一步 / 清除任务记录);
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
* 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
* 右侧 = <Outlet/> 渲染选中的对话(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 (
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r">
<div className="border-border/70 border-b p-2">
<Button size="sm" className="w-full" onClick={() => navigate(`${chatsBase}/new`)}>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{isLoading ? (
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
thread.thread_id === currentThreadId && "bg-accent font-medium",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
</div>
);
}
/**
* 右下角常驻红色操作浮层:编辑 / 下一步 / 清除任务记录。
* 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 (
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r">
<div className="border-border/70 flex flex-col gap-1.5 border-b p-2">
<Button size="sm" onClick={() => navigate(`${chatsBase}/new`)}>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
>
<ArrowRight className="h-3.5 w-3.5" /> 下一步
</Button>
</div>
<>
<div className="pointer-events-none absolute bottom-4 right-4 z-40 flex flex-col items-end gap-2">
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive justify-start"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
>
<ArrowRight className="h-3.5 w-3.5" /> 下一步
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => setClearOpen(true)}
disabled={!taskId}
>
@@ -108,29 +151,6 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{isLoading ? (
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
thread.thread_id === thread_id && "bg-accent font-medium",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
<DialogContent>
<DialogHeader>
@@ -143,17 +163,17 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
<Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}>
取消
</Button>
<Button onClick={handleClear} disabled={clearing}>
<Button variant="destructive" onClick={handleClear} disabled={clearing}>
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}
/** 任务工作区布局:左列表 + 右 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 (
<div className="flex size-full min-h-0 overflow-hidden">
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
<div className="min-h-0 min-w-0 flex-1">
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
<TaskFloatingActions taskId={taskId} />
</div>
</div>
);
@@ -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 (
@@ -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 <div className="text-muted-foreground py-1 text-sm">暂无目的数据,可直接开始问答。</div>;
}
function PurposeNodes({ nodes, depth = 0 }: { nodes: PurposeTreeNode[]; depth?: number }) {
return (
<ul className={depth === 0 ? "space-y-1" : "mt-1 space-y-1 border-l border-border/60 pl-3"}>
{nodes.map((node, i) => (
<li key={`${depth}-${i}-${node.text}`} className="text-sm leading-relaxed">
<span className="text-muted-foreground mr-1 rounded bg-muted px-1 py-0.5 text-xs">
{node.label}
</span>
<span className="font-medium">{node.text || "(空)"}</span>
{node.reason ? (
<span className="text-muted-foreground ml-2 text-xs">理由:{node.reason}</span>
) : null}
{node.children.length > 0 ? (
<PurposeNodes nodes={node.children} depth={depth + 1} />
) : null}
</li>
))}
</ul>
<div className={cn("overflow-auto", className)}>
<table className="w-full border-collapse text-sm">
<thead>
<tr>
{headers.map((h, i) => (
<th
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}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="even:bg-muted/30 align-top">
{headers.map((_, ci) => {
const cell = row[ci];
return (
<td key={ci} className="border-border/60 border px-3 py-1.5">
{cell ? (
<>
<span>{cell.text || "(空)"}</span>
{cell.reason ? (
<span className="text-muted-foreground block text-xs">理由:{cell.reason}</span>
) : null}
</>
) : (
<span className="text-muted-foreground/60">—</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -61,6 +102,7 @@ export function TaskPurposePanel({
const [result, setResult] = useState<TaskPurposeResult | null>(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 (
<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 size="sm" onClick={handleStart} disabled={loading || working}>
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
</Button>
<div className="flex items-center gap-2">
{hasTree && (
<Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
<Maximize2 className="h-3.5 w-3.5" /> 弹框查看
</Button>
)}
<Button size="sm" onClick={handleStart} disabled={loading || working}>
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
</Button>
</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>
) : result && result.tree.length > 0 ? (
<div className="max-h-48 overflow-y-auto pr-1">
<PurposeNodes nodes={result.tree} />
</div>
) : (
<div className="text-muted-foreground py-1 text-sm">暂无目的数据,可直接开始问答。</div>
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
)}
{/* 弹框查看:放大显示完整表格 */}
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>任务目的(任务 {taskId})</DialogTitle>
</DialogHeader>
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
</DialogContent>
</Dialog>
{/* 覆盖确认 */}
<Dialog open={confirmOpen} onOpenChange={(o) => !working && setConfirmOpen(o)}>
<DialogContent>
<DialogHeader>