diff --git a/agent/planner.go b/agent/planner.go index 1d09501..60aacad 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -109,6 +109,8 @@ func renderPlannerTodos(items []actool.Todo) string { // "finding" — a worker reported a finding on intent IntentID (Detail = 摘要). // "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a // single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量). +// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is +// stopped (not deleted) and the reason is attached to it as a fact. type TriggerEvent struct { Kind string IntentID int64 @@ -136,6 +138,8 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string { } case "finding": b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail)) + case "cancelled": + b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已停止(不再执行),其原因已作为事实挂在该意图上;请据此重新规划。", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail)) default: // "done" b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID))) } diff --git a/db/exploration.go b/db/exploration.go index e86bf19..b366a0d 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -306,6 +306,67 @@ type IntentCleanup struct { Activities int64 `json:"activities"` } +// StopIntentWithReason marks a running/paused intent as 'stopped' WITHOUT deleting +// it or any of its yielded facts/findings/activities, and records the user's reason +// as a fact node hanging off that intent (intent --yields--> fact). Returns the new +// fact node id. The caller must first stop a running worker to prevent late writes. +// Used by the "delete work" action, which no longer destroys data — it stops the +// intent and attaches why, so the planner can account for it. +func (s *ExplorationStore) StopIntentWithReason(id int64, reason, origin string) (int64, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + var state string + var rawPayload []byte + if err := tx.QueryRow(`SELECT state, payload FROM exploration_nodes + WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(&state, &rawPayload); err != nil { + if err == sql.ErrNoRows { + return 0, fmt.Errorf("intent not found") + } + return 0, err + } + if state != "running" && state != "paused" { + return 0, fmt.Errorf("%w: intent state %s cannot be cancelled", ErrIntentStateConflict, state) + } + // Merge the delete marker into the intent's own payload so it travels with the + // intent everywhere (session list badge, detail banner) without a schema change. + ip := map[string]any{} + _ = json.Unmarshal(rawPayload, &ip) + ip["cancelled_by_user"] = true + ip["cancel_reason"] = reason + newPayload, _ := json.Marshal(ip) + if _, err := tx.Exec(`UPDATE exploration_nodes SET state='stopped', payload=$3, blocked_reason=NULL + WHERE id=$1 AND exploration_id=$2`, id, s.expID, string(newPayload)); err != nil { + return 0, err + } + + if origin == "" { + origin = "user" + } + raw, _ := json.Marshal(map[string]any{ + "summary": "用户删除了该意图", + "detail": "删除原因:" + reason, + "confidence": "observed", + "user_cancel": true, + }) + var factID int64 + if err := tx.QueryRow(` +INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, origin) +VALUES ($1, 'fact', $2, 5, 'confirmed', $3) RETURNING id`, + s.expID, string(raw), origin).Scan(&factID); err != nil { + return 0, err + } + if _, err := tx.Exec(` +INSERT INTO exploration_edges(exploration_id, src_id, rel, dst_id) VALUES ($1,$2,$3,$4) +ON CONFLICT DO NOTHING`, s.expID, id, RelYields, factID); err != nil { + return 0, err + } + return factID, tx.Commit() +} + // CancelIntent removes one local intent and the fact/finding nodes it directly // yielded. The standalone finding row must be deleted before its node (whose FK // otherwise uses ON DELETE SET NULL), so the entire cleanup is kept in one DB diff --git a/server/manager.go b/server/manager.go index 38ce41b..4e03734 100644 --- a/server/manager.go +++ b/server/manager.go @@ -1488,6 +1488,18 @@ func (t *Task) NotifyGoal(texts []string) { t.Notify() } +// NotifyCancelled records that the human deleted intentID (reason = 删除原因), then +// wakes the planner so the next round spells out which intent was removed and why. +// The intent is stopped (not deleted); the reason is attached to it as a fact. +func (t *Task) NotifyCancelled(intentID int64, reason string) { + if intentID > 0 { + t.trigMu.Lock() + t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "cancelled", IntentID: intentID, Detail: reason}) + t.trigMu.Unlock() + } + t.Notify() +} + // drainTriggers returns and clears the trigger events accumulated since the last round. func (t *Task) drainTriggers() []agent.TriggerEvent { t.trigMu.Lock() diff --git a/server/server.go b/server/server.go index ced79d9..e39e2ab 100644 --- a/server/server.go +++ b/server/server.go @@ -1055,6 +1055,7 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) { } var req struct { Action string `json:"action"` + Reason string `json:"reason"` // cancel(删除)时必填:删除原因,挂为事实并告知 planner } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "bad json: "+err.Error()) @@ -1065,7 +1066,7 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) { return } - result, err := s.applyIntentControl(r.Context(), t, iid, req.Action) + result, err := s.applyIntentControl(r.Context(), t, iid, req.Action, req.Reason) if err != nil { writeErr(w, 409, err.Error()) return diff --git a/server/task_control.go b/server/task_control.go index 5e5f9ca..4e61f57 100644 --- a/server/task_control.go +++ b/server/task_control.go @@ -115,7 +115,7 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er return out, nil } -func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, action string) (intentControlResult, error) { +func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, action, reason string) (intentControlResult, error) { out := intentControlResult{ID: iid} node, err := t.Store.GetNode(iid) if err != nil { @@ -153,20 +153,25 @@ func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, act t.Notify() out.State = "open" case "cancel": + // 「删除」新语义:不销毁意图与产出,而是把意图停到 stopped、把用户填写的删除原因 + // 作为一条事实挂到该意图上,并用 cancelled 触发告知 planner(意图内容 + 删除原因)。 if node.State != "running" && node.State != "paused" { - return out, fmt.Errorf("仅运行中或已暂停的意图可以取消") + return out, fmt.Errorf("仅运行中或已暂停的意图可以删除") + } + reason = strings.TrimSpace(reason) + if reason == "" { + return out, fmt.Errorf("请填写删除原因") } if node.State == "running" { if err := s.engine.ControlWork(ctx, iid, "cancel"); err != nil { return out, err } } - deleted, err := t.Store.CancelIntent(iid) - if err != nil { + if _, err := t.Store.StopIntentWithReason(iid, reason, "user"); err != nil { return out, err } - t.Notify() - out.State, out.Deleted = "cancelled", &deleted + t.NotifyCancelled(iid, reason) + out.State = "stopped" default: return out, fmt.Errorf("action must be pause|resume|cancel") } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 1966857..d5f469c 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -41,6 +41,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; +import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { api, sseUrl } from "@/lib/api"; @@ -313,6 +314,7 @@ function SessionItem({ onResume, onCancel, controlling, + deleted, }: { s: Session; active: boolean; @@ -324,13 +326,15 @@ function SessionItem({ onResume?: () => void; onCancel?: () => void; controlling?: boolean; + deleted?: boolean; }) { - const icon = - s.role === "worker" ? ( - statusIcon(s.status) - ) : s.live ? ( - - ) : null; + const icon = deleted ? ( + + ) : s.role === "worker" ? ( + statusIcon(s.status) + ) : s.live ? ( + + ) : null; const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused"); return ( @@ -356,7 +360,14 @@ function SessionItem({ 来源 #{s.source_task_id} )} - {displayTitle} + + {displayTitle} + + {deleted && ( + + 已删除 + + )} {hasPending && } {!active && unread ? ( @@ -403,8 +414,8 @@ function SessionItem({ size="icon-xs" onClick={onCancel} disabled={controlling} - title="取消并清理 Worker 数据" - aria-label="取消并清理 Worker 数据" + title="删除该意图(需填写原因,保留数据)" + aria-label="删除该意图(需填写原因,保留数据)" className="text-destructive hover:text-destructive" > @@ -432,6 +443,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { const [mainChatRunning, setMainChatRunning] = React.useState(null); const [controllingIntent, setControllingIntent] = React.useState(null); const [cancelIntent, setCancelIntent] = React.useState(null); + const [cancelReason, setCancelReason] = React.useState(""); // 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。 const [attachments, setAttachments] = React.useState([]); const [uploading, setUploading] = React.useState(false); @@ -461,11 +473,15 @@ export function SessionsTab({ taskId }: { taskId: string }) { }, []); const controlWorker = React.useCallback( - async (session: Session, action: "pause" | "resume" | "cancel") => { + async (session: Session, action: "pause" | "resume" | "cancel", reason?: string) => { if (!session.intent_id || session.inherited || controllingIntent) return; + if (action === "cancel" && !reason?.trim()) { + toast.error("请填写删除原因"); + return; + } setControllingIntent(session.intent_id); try { - const result = await api.controlIntent(taskId, session.intent_id, action); + await api.controlIntent(taskId, session.intent_id, action, reason); if (action === "pause") { patchIntentState(session.intent_id, "paused"); toast.success(`Worker #${session.intent_id} 已暂停`); @@ -473,14 +489,10 @@ export function SessionsTab({ taskId }: { taskId: string }) { patchIntentState(session.intent_id, "open"); toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`); } else { - patchIntentState(session.intent_id); - if (activeId === session.id) setActiveId(MAIN_ID); - const deleted = result.deleted; - toast.success( - deleted - ? `Worker #${session.intent_id} 已取消,清理 ${deleted.facts} 条事实、${deleted.findings} 个漏洞` - : `Worker #${session.intent_id} 已取消`, - ); + // 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。 + patchIntentState(session.intent_id, "stopped"); + toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`); + setCancelReason(""); } } catch (error) { toast.error(`Worker 操作失败:${(error as Error).message}`); @@ -489,7 +501,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { setCancelIntent(null); } }, - [activeId, controllingIntent, patchIntentState, taskId], + [controllingIntent, patchIntentState, taskId], ); // SSE connection state — surfaced so a dropped realtime link is visible, never // silently shown as "no messages". @@ -964,21 +976,27 @@ export function SessionsTab({ taskId }: { taskId: string }) { // For each worker session (intent), derive the display title from the intent // payload summary. Also store the full TaskNode for the hover-JSON tooltip. const sessionMeta = React.useMemo(() => { - const map = new Map(); + const map = new Map(); for (const node of allIntents) { let title = `Intent ${node.id}`; let parsedPayload: unknown = node.payload; + let deleted = false; + let deleteReason = ""; 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; } } const json = { ...node, payload: parsedPayload }; - map.set(node.id, { title, json }); + map.set(node.id, { title, json, deleted, deleteReason }); } return map; }, [allIntents]); @@ -1327,6 +1345,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { unread={store[keyForSession(s)]?.unread} onClick={() => setActiveId(s.id)} controlling={controllingIntent === s.intent_id} + deleted={meta?.deleted} onPause={() => void controlWorker(s, "pause")} onResume={() => void controlWorker(s, "resume")} onCancel={() => { @@ -1440,6 +1459,27 @@ export function SessionsTab({ taskId }: { taskId: string }) { {isMain && 可交互} + {(() => { + const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined; + if (!dm?.deleted) return null; + return ( +
+ +
+ 此意图已被用户删除 + + (已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史) + + {dm.deleteReason && ( +

+ 删除原因: + {dm.deleteReason} +

+ )} +
+
+ ); + })()} {/* 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 @@ -1569,24 +1609,44 @@ export function SessionsTab({ taskId }: { taskId: string }) { )} - !open && setCancelIntent(null)}> + { + if (!open) { + setCancelIntent(null); + setCancelReason(""); + } + }} + > - 取消 Worker #{cancelIntent?.intent_id}? + 删除 Worker #{cancelIntent?.intent_id}? - 取消后将删除该意图、执行记录,以及该意图直接登记的事实和漏洞;全局资产、流量和其他 Planner - 意图不会删除。此操作不可撤销。 + 删除会停止该意图(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞都会保留。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。 +
+ +