feat(intent): 意图删除支持假删除/真删除两种模式

删除入口区分两种模式(待领/运行中/已暂停的意图均可删除):

- 假删除(默认):意图置 state='deleted'、删除原因记入新字段 delete_reason,
  保留节点与全部产出/血缘,不再在图上另挂 fact。
- 真删除:物理删除意图及「仅由它支撑」的独占子孙(沿 yields/derived_from
  级联到叶子),避免孤立数据;共享节点、目标、任务根事实保留。被删意图先做
  token rollup 并清理 activity/副会话,被删 finding 先删 findings 行。

两种模式都照常用 cancelled 触发通知 planner(带删除前捕获的意图摘要 + 原因),
planner 文案不再声称原因挂为事实。

- DB: 新增 SoftDeleteIntent、重写 CancelIntent 为深度独占级联;schema 加
  delete_reason 列与 intent 的 deleted 状态(含旧库迁移)。
- 前端: 删除对话框加模式切换与动态说明,状态徽章/图标新增「已删除」,
  播报板展开展示删除原因。
This commit is contained in:
Autumn-27 committed 2026-09-17 12:52:57 -04:00
1 parent 6e3dbf6452
commit 4756bb1c45
17 files changed
+593 -238

No files matched your search

@@ -405,6 +405,12 @@ function BroadcastRow({
<span>来源 {node.origin || "system"}</span>
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span>
</div>
{node.state === "deleted" && node.delete_reason && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
<span className="font-medium text-destructive">删除原因</span>
<span className="ml-2 break-words text-muted-foreground">{node.delete_reason}</span>
</div>
)}
<AssetList assets={assets[node.id] ?? []} />
{(upstream.length > 0 || downstream.length > 0) && (
<div className="flex flex-col gap-3 sm:flex-row">
@@ -27,9 +27,10 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
import { MentionTextarea } from "@/components/mention-textarea";
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
import { TodoPopover } from "@/components/todo-popover";
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
import { Transcript } from "@/components/transcript";
import {
AlertDialog,
@@ -42,7 +43,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { MentionTextarea } from "@/components/mention-textarea";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -181,6 +181,8 @@ function statusIcon(status: SessionStatus) {
return <CircleXIcon className="size-3.5 text-red-500" />;
case "exhausted": // 步数耗尽(撞 max_turns)
return <ZapOffIcon className="size-3.5 text-violet-500" />;
case "deleted": // 用户假删除
return <CircleSlashIcon className="size-3.5 text-muted-foreground" />;
}
}
@@ -309,6 +311,8 @@ function intentStatus(state: string): SessionStatus {
return "paused";
case "open": // 待领取,区别于执行中
return "pending";
case "deleted": // 用户假删除
return "deleted";
default: // running
return "running";
}
@@ -362,7 +366,11 @@ function SessionItem({
) : null;
const cancellable =
s.role === "worker" && !s.inherited && !deleted && (s.status === "running" || s.status === "paused");
s.role === "worker" &&
!s.inherited &&
!deleted &&
// pending = 待领(open)意图;连同运行中/已暂停都允许删除。
(s.status === "running" || s.status === "paused" || s.status === "pending");
return (
<div
className={cn(
@@ -417,8 +425,8 @@ function SessionItem({
size="icon-xs"
onClick={onCancel}
disabled={controlling}
title="删除该意图(需填写原因,保留数据)"
aria-label="删除该意图(需填写原因,保留数据)"
title="删除该意图(需填写原因,可选假删除/真删除)"
aria-label="删除该意图(需填写原因,可选假删除/真删除)"
className="text-destructive hover:text-destructive"
>
<Trash2Icon />
@@ -528,6 +536,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
const [cancelReason, setCancelReason] = React.useState("");
// 删除模式:soft=假删除(默认,置 deleted + 记原因,保留数据)| hard=真删除(级联移除独占子孙)。
const [deleteMode, setDeleteMode] = React.useState<"soft" | "hard">("soft");
const [workerMessage, setWorkerMessage] = React.useState("");
const [workerMessageRequestId, setWorkerMessageRequestId] = React.useState("");
const [workerMessageSending, setWorkerMessageSending] = React.useState(false);
@@ -583,7 +593,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}, []);
const controlWorker = React.useCallback(
async (session: Session, action: "pause" | "resume" | "cancel", reason?: string) => {
async (session: Session, action: "pause" | "resume" | "cancel", reason?: string, mode?: "soft" | "hard") => {
if (!session.intent_id || session.inherited || controllingIntent) return;
if (action === "cancel" && !reason?.trim()) {
toast.error("请填写删除原因");
@@ -591,16 +601,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}
setControllingIntent(session.intent_id);
try {
await api.controlIntent(taskId, session.intent_id, action, reason);
const res = await api.controlIntent(taskId, session.intent_id, action, reason, mode);
if (action === "pause") {
patchIntentState(session.intent_id, "paused");
toast.success(`Worker #${session.intent_id} 已暂停`);
} else if (action === "resume") {
patchIntentState(session.intent_id, "open");
toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`);
} else if (mode === "hard") {
// 真删除:意图及独占下游已物理移除,从列表剔除该行。
patchIntentState(session.intent_id);
const d = res.deleted;
const extra = d ? `(含 ${d.intents} 意图 / ${d.facts} 事实 / ${d.findings} 漏洞)` : "";
toast.success(`Worker #${session.intent_id} 及其独占下游已彻底删除${extra}`);
setCancelReason("");
} else {
// 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。
patchIntentState(session.intent_id, "stopped");
// 假删除:意图置 deleted、记录删除原因,保留节点与产出。
patchIntentState(session.intent_id, "deleted");
toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`);
setCancelReason("");
}
@@ -1139,17 +1156,14 @@ export function SessionsTab({ taskId }: { taskId: string }) {
for (const node of allIntents) {
let title = `Intent ${node.id}`;
let parsedPayload: unknown = node.payload;
let deleted = false;
let deleteReason = "";
// 假删除:意图 state='deleted',删除原因在独立字段 delete_reason 上。
const deleted = node.state === "deleted";
const deleteReason = node.delete_reason ?? "";
if (node.payload) {
try {
const p = JSON.parse(node.payload);
parsedPayload = p;
if (p?.summary) title = String(p.summary);
if (p?.cancelled_by_user) {
deleted = true;
deleteReason = String(p.cancel_reason ?? "");
}
} catch {
title = node.payload.trim() || title;
}
@@ -1288,16 +1302,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}, [activeKey]);
const focusKey = approvalFocus.state?.source?.session;
const loadFocusPage = React.useCallback((before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE), [taskId, focusKey]);
const mergeFocusPage = React.useCallback((page: { items: Activity[]; hasMore: boolean }) => {
if (!focusKey) return;
patchStore(focusKey, (s) => {
const items = mergeBySeq(page.items, s.items);
return { ...s, items, hasMore: page.hasMore, earliestSeq: items[0]?.seq ?? s.earliestSeq };
});
}, [focusKey, patchStore]);
const focusHistory = useApprovalHistory(approvalFocus.state?.source, !!focusKey && !!store[focusKey]?.loaded,
focusKey ? store[focusKey]?.items ?? [] : [], loadFocusPage, mergeFocusPage);
const loadFocusPage = React.useCallback(
(before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE),
[taskId, focusKey],
);
const mergeFocusPage = React.useCallback(
(page: { items: Activity[]; hasMore: boolean }) => {
if (!focusKey) return;
patchStore(focusKey, (s) => {
const items = mergeBySeq(page.items, s.items);
return { ...s, items, hasMore: page.hasMore, earliestSeq: items[0]?.seq ?? s.earliestSeq };
});
},
[focusKey, patchStore],
);
const focusHistory = useApprovalHistory(
approvalFocus.state?.source,
!!focusKey && !!store[focusKey]?.loaded,
focusKey ? (store[focusKey]?.items ?? []) : [],
loadFocusPage,
mergeFocusPage,
);
React.useEffect(() => {
if (approvalFocus.state) atBottomRef.current = false;
}, [approvalFocus.state]);
@@ -1828,10 +1853,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div>
);
})()}
<ApprovalExecutionFocus focus={{ ...approvalFocus, close: () => {
setActiveId(activeId);
approvalFocus.close();
} }} history={focusHistory} />
<ApprovalExecutionFocus
focus={{
...approvalFocus,
close: () => {
setActiveId(activeId);
approvalFocus.close();
},
}}
history={focusHistory}
/>
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
out the width and defeat the truncation below — the transcript wraps to
@@ -1863,7 +1894,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</Button>
</div>
) : activity.length ? (
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} focusedSeq={focusHistory.ready ? approvalFocus.state?.source?.seq : undefined} />
<Transcript
activity={activity}
live={active.live}
taskId={taskId}
chat={isMain}
focusedSeq={focusHistory.ready ? approvalFocus.state?.source?.seq : undefined}
/>
) : (
<div className="pl-9 text-xs text-muted-foreground">
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
@@ -1908,7 +1945,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
inputGroup
rows={1}
aria-label="给主 Agent 发消息"
placeholder={mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"}
placeholder={
mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"
}
value={input}
disabled={sending}
onValueChange={setInput}
@@ -2069,6 +2108,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
if (!open) {
setCancelIntent(null);
setCancelReason("");
setDeleteMode("soft");
}
}}
>
@@ -2076,33 +2116,68 @@ export function SessionsTab({ taskId }: { taskId: string }) {
<AlertDialogHeader>
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words whitespace-normal">
删除会<strong>停止该意图</strong>
(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞
<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
{deleteMode === "hard" ? (
<>
<strong>真删除</strong>会物理移除该意图,以及<strong>仅由它支撑</strong>
的下游节点(级联到叶子,避免留下孤立数据);共享节点、目标和任务根事实会保留。
<strong>此操作不可恢复。</strong>规划者会收到删除通知并据此重新规划。
</>
) : (
<>
<strong>假删除</strong>会把该意图置为「已删除」并记录删除原因,意图节点、执行记录、
已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-2 py-1">
<label htmlFor="cancel-reason" className="text-sm font-medium">
删除原因(必填)
</label>
<Textarea
id="cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…"
rows={3}
autoFocus
/>
<div className="grid gap-3 py-1">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setDeleteMode("soft")}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
deleteMode === "soft" ? "border-primary bg-primary/5" : "hover:bg-accent",
)}
>
<div className="font-medium">假删除</div>
<div className="text-xs text-muted-foreground">保留数据,可追溯</div>
</button>
<button
type="button"
onClick={() => setDeleteMode("hard")}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
deleteMode === "hard" ? "border-destructive bg-destructive/5" : "hover:bg-accent",
)}
>
<div className="font-medium">真删除</div>
<div className="text-xs text-muted-foreground">级联移除,不可恢复</div>
</button>
</div>
<div className="grid gap-2">
<label htmlFor="cancel-reason" className="text-sm font-medium">
删除原因(必填)
</label>
<Textarea
id="cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…"
rows={3}
autoFocus
/>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>返回</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!cancelIntent || controllingIntent !== null || !cancelReason.trim()}
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason)}
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason, deleteMode)}
>
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
确认删除
{deleteMode === "hard" ? "彻底删除" : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+10 -3
View File
@@ -315,12 +315,19 @@ export const api = {
deleteTaskArchive: (id: number) => del<TaskArchive>(`/task-archives/${id}`),
deleteTaskArchives: (archiveIds: number[]) =>
post<{ items: ArchiveBatchItem[] }>("/task-archives/delete/batch", { archive_ids: archiveIds }),
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel", reason?: string) =>
controlIntent: (
taskId: string,
intentId: string,
action: "pause" | "resume" | "cancel",
reason?: string,
// cancel 专用:soft(默认,假删除,意图置 deleted + 记删除原因)| hard(真删除,级联移除独占子孙)。
mode?: "soft" | "hard",
) =>
post<{
id: number;
state: "paused" | "open" | "stopped";
state: "paused" | "open" | "deleted" | "";
deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "", mode: mode ?? "soft" }),
sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) =>
post<{
id: number;
+24 -12
View File
@@ -1452,20 +1452,32 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (b.action === "cancel") {
const index = mockIntents.findIndex((item) => item.id === id);
const intent = mockIntents[index];
if (!intent || intent.inherited || (intent.state !== "running" && intent.state !== "paused")) {
if (
!intent ||
intent.inherited ||
(intent.state !== "running" && intent.state !== "paused" && intent.state !== "open")
) {
throw new Error("Worker 状态已变化");
}
mockIntents.splice(index, 1);
return {
id: Number(id.replace(/\D/g, "")) || 0,
state: "cancelled",
deleted: {
intents: 1,
facts: 1,
findings: 1,
activities: mockActivity.filter((item) => item.intent_id === id).length,
},
};
const numId = Number(id.replace(/\D/g, "")) || 0;
if (b.mode === "hard") {
// 真删除:从列表移除,返回级联删除计数。
mockIntents.splice(index, 1);
return {
id: numId,
state: "",
deleted: {
intents: 1,
facts: 1,
findings: 1,
activities: mockActivity.filter((item) => item.intent_id === id).length,
},
};
}
// 假删除(默认):置 deleted + 记删除原因,保留节点。
intent.state = "deleted";
intent.delete_reason = String(b.reason ?? "");
return { id: numId, state: "deleted" };
}
const action = b.action === "resume" ? "resume" : "pause";
const result = controlMockIntent(id, action);
+9 -20
View File
@@ -1,31 +1,18 @@
// Centralised status → color/label semantics, reused across the whole app.
// Spec §8.3: 意图 / 覆盖 / 任务 / 严重度 each have a consistent color set.
export type Tone =
| "neutral"
| "blue"
| "green"
| "amber"
| "red"
| "rose"
| "violet"
| "slate";
export type Tone = "neutral" | "blue" | "green" | "amber" | "red" | "rose" | "violet" | "slate";
export const toneClasses: Record<Tone, string> = {
neutral:
"bg-muted text-muted-foreground border-transparent",
neutral: "bg-muted text-muted-foreground border-transparent",
blue: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
green:
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
amber:
"bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
green: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
amber: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
// rose 用作「严重」——实心高强调,视觉上明显高于「高危」的软红描边。
rose: "bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:text-white",
violet:
"bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20",
slate:
"bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/20",
violet: "bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20",
slate: "bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/20",
};
export const toneDot: Record<Tone, string> = {
@@ -53,8 +40,10 @@ const intent: Record<string, StatusMeta> = {
blocked: { label: "执行出错", tone: "red" },
// exhausted = 达到步数/时间预算被中途掐断、只写回部分结果(非方向已探尽)。
exhausted: { label: "预算耗尽", tone: "violet" },
// stopped = 用户手动删除/停止了该 work(保留已产出的事实)。
// stopped = 历史软删除状态(保留,历史数据)。
stopped: { label: "已停止", tone: "slate" },
// deleted = 用户假删除了该意图(保留节点与血缘,删除原因见 delete_reason 字段)。
deleted: { label: "已删除", tone: "slate" },
};
const task: Record<string, StatusMeta> = {
+2 -1
View File
@@ -418,6 +418,7 @@ export interface TaskNode {
ts: string;
source_task_id?: string;
inherited?: boolean;
delete_reason?: string; // 意图假删除(state='deleted')时的删除原因
}
// 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引),
@@ -754,7 +755,7 @@ export interface LogLine {
}
export type SessionRole = "mainagent" | "planner" | "worker" | "system";
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped" | "deleted";
// Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket {