From d28ecd67fe4851065481b0786f3365205ff7113c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:37:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(goals):=20=E6=80=BB=E8=A7=88=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9B=AE=E6=A0=87=E7=AE=A1=E7=90=86(=E5=A2=9E/?= =?UTF-8?q?=E5=88=A0/=E6=94=B9),=E5=90=8C=E6=AD=A5=E9=80=9A=E7=9F=A5=20pla?= =?UTF-8?q?nner=20=E5=B9=B6=E5=A4=8D=E6=B4=BB=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 总览页新增「目标管理」卡片,支持人工查看/新增/修改/删除目标 - 新增/修改:通知 planner(新增了目标/由 old 变为 new)并复活任务 - 删除:硬删除 goal 节点(级联删边/锚点),通知 planner,不复活任务 - 无 schema 变更:目标仍存 exploration_nodes(kind='goal'),仅加查询方法 --- agent/planner.go | 8 + db/exploration.go | 34 +++ server/dto.go | 35 +++ server/goals_api.go | 172 +++++++++++++++ server/manager.go | 28 +++ server/server.go | 4 + .../tasks/detail/_tabs/overview-tab.tsx | 204 +++++++++++++++++- web/src/lib/api.ts | 13 ++ web/src/lib/types.ts | 10 + 9 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 server/goals_api.go diff --git a/agent/planner.go b/agent/planner.go index 60aacad..e08e544 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 支持批量). +// "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本). +// "goal_edited" — the human edited a goal from 总览的目标管理 (OldGoal→NewGoal 文本). // "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 { @@ -116,6 +118,8 @@ type TriggerEvent struct { IntentID int64 Detail string Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条) + OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本 + NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本 } // renderTriggers spells out the change(s) that fired this round: for a finished @@ -136,6 +140,10 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string { } else { b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 个目标:%s —— 均为新的待达成目标,请逐一为尚无对应意图的目标补充探索方向。", len(ev.Goals), strings.Join(ev.Goals, ";"))) } + case "goal_deleted": + b.WriteString(fmt.Sprintf("\n- 人删除了该目标:%s —— 该目标已移除,请据此重判剩余目标/方向(不必再为它派意图)。", ev.Detail)) + case "goal_edited": + b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal)) case "finding": b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail)) case "cancelled": diff --git a/db/exploration.go b/db/exploration.go index b366a0d..8747fa6 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -228,6 +228,40 @@ func (s *ExplorationStore) SetNodeState(id int64, state string) error { return err } +// UpdateGoalPayload rewrites a goal node's payload text (and optional vulnclass); +// scoped to kind='goal' so it can never mutate an intent/fact by id. Returns an +// error if no such goal exists in this exploration. +func (s *ExplorationStore) UpdateGoalPayload(id int64, text, vulnclass string) error { + payload := map[string]any{"text": text} + if vulnclass != "" { + payload["vulnclass"] = vulnclass + } + raw, _ := json.Marshal(payload) + res, err := s.db.Exec(`UPDATE exploration_nodes SET payload=$1 WHERE id=$2 AND exploration_id=$3 AND kind='goal'`, string(raw), id, s.expID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("目标不存在") + } + return nil +} + +// DeleteGoal hard-deletes a goal node; scoped to kind='goal' so it can never drop +// an intent/fact by id. The edges (spawns) and anchors reference it with ON DELETE +// CASCADE, so they go with it; activity.node_id is ON DELETE SET NULL. Returns an +// error if no such goal exists in this exploration. +func (s *ExplorationStore) DeleteGoal(id int64) error { + res, err := s.db.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND exploration_id=$2 AND kind='goal'`, id, s.expID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("目标不存在") + } + return nil +} + // SetIntentState updates an intent node's state. // ResetRunningIntents returns any intent left in 'running' back to 'open' so it // is re-claimed. Called on startup: a 'running' intent with no live worker (a diff --git a/server/dto.go b/server/dto.go index 6a9f62f..5e9ade9 100644 --- a/server/dto.go +++ b/server/dto.go @@ -173,6 +173,41 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO { return d } +// GoalDTO is a goal node with its payload unpacked into text/vulnclass — the shape +// the 总览「目标管理」UI works with (vs TaskNodeDTO which carries raw payload JSON). +type GoalDTO struct { + ID string `json:"id"` + Text string `json:"text"` + VulnClass string `json:"vulnclass,omitempty"` + State string `json:"state"` + Origin string `json:"origin,omitempty"` + TS string `json:"ts"` +} + +func goalDTO(n *db.Node) GoalDTO { + var p struct { + Text string `json:"text"` + VulnClass string `json:"vulnclass"` + } + _ = json.Unmarshal(n.Payload, &p) + return GoalDTO{ + ID: i64s(n.ID), + Text: p.Text, + VulnClass: p.VulnClass, + State: n.State, + Origin: n.Origin, + TS: rfc3339(n.CreatedAt), + } +} + +func goalDTOs(in []*db.Node) []GoalDTO { + out := make([]GoalDTO, 0, len(in)) + for _, n := range in { + out = append(out, goalDTO(n)) + } + return out +} + func taskNodeDTOs(in []*db.Node) []TaskNodeDTO { out := make([]TaskNodeDTO, 0, len(in)) for _, n := range in { diff --git a/server/goals_api.go b/server/goals_api.go new file mode 100644 index 0000000..ede0b98 --- /dev/null +++ b/server/goals_api.go @@ -0,0 +1,172 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/Autumn-27/artex/db" +) + +// 总览「目标管理」的人工 CRUD 接口。与 agent 侧的 set_goals 工具写同一批 goal 节点, +// 但入口是人类在 UI 上直接增删改;新增/修改后复用「复活任务」逻辑(admitTask resume: +// 终态→running、解除暂停、必要时排队),删除不复活(按产品决策)。每个变更 handler 都走 +// beginTaskOperation/decInflight,避免与任务删除竞态(与意图 CRUD 一致)。 + +// listGoals 返回本任务的全部目标(text/vulnclass/state 拆好),供目标管理卡片渲染。 +func (s *Server) listGoals(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + goals, err := t.Store.ListByKind(db.KindGoal, 10000) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"goals": goalDTOs(goals)}) +} + +// addGoal 人工新增一个目标:落库(挂到任务根 spawns 下)→ 记一条「新增了目标」触发唤醒 +// planner → 复活任务,让规划者据新目标重判是否达成。 +func (s *Server) addGoal(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法新增目标") + return + } + defer s.engine.decInflight(t.ID) + + var body struct { + Text string `json:"text"` + VulnClass string `json:"vulnclass"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + text := strings.TrimSpace(body.Text) + if text == "" { + writeErr(w, 400, "目标内容不能为空") + return + } + payload := map[string]any{"text": text} + if vc := strings.TrimSpace(body.VulnClass); vc != "" { + payload["vulnclass"] = vc + } + id, err := t.Store.AddGoal(payload, "human") + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if of, _ := t.Store.OriginFactID(); of > 0 && id > 0 { + _ = t.Store.Link(of, db.RelSpawns, id) // goal descends from the task root (origin fact) + } + t.NotifyGoal([]string{text}) // 记「人新增了目标:…」触发并唤醒 planner + s.reviveTask(t) // 把已完成/暂停的任务拉回运行态继续跑 + node, _ := t.Store.GetNode(id) + if node == nil { + writeErr(w, 500, "目标写入后读取失败") + return + } + writeJSON(w, 200, goalDTO(node)) +} + +// editGoal 人工修改一个目标文本(及 vulnclass):改库 → 记「用户修改了目标由 old 变为 new」 +// 触发唤醒 planner → 复活任务,让规划者据新目标调整方向。 +func (s *Server) editGoal(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法修改目标") + return + } + defer s.engine.decInflight(t.ID) + + gid, err := strconv.ParseInt(r.PathValue("gid"), 10, 64) + if err != nil || gid <= 0 { + writeErr(w, 400, "bad goal id") + return + } + var body struct { + Text string `json:"text"` + VulnClass string `json:"vulnclass"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + text := strings.TrimSpace(body.Text) + if text == "" { + writeErr(w, 400, "目标内容不能为空") + return + } + node, err := t.Store.GetNode(gid) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if node == nil || node.Kind != db.KindGoal { + writeErr(w, 404, "目标不存在") + return + } + oldText := goalDTO(node).Text + if err := t.Store.UpdateGoalPayload(gid, text, strings.TrimSpace(body.VulnClass)); err != nil { + writeErr(w, 500, err.Error()) + return + } + t.NotifyGoalEdited(oldText, text) // 记「人修改了目标由 old 变为 new」触发并唤醒 planner + s.reviveTask(t) // 与新增一致:复活任务据新目标重判 + updated, _ := t.Store.GetNode(gid) + if updated == nil { + writeErr(w, 500, "目标更新后读取失败") + return + } + writeJSON(w, 200, goalDTO(updated)) +} + +// deleteGoal 人工删除一个目标(硬删除,级联删边/锚点):删库 → 记「用户删除了该目标 X」 +// 触发唤醒 planner 据此重判剩余目标。按产品决策,删除【不】复活任务。 +func (s *Server) deleteGoal(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法删除目标") + return + } + defer s.engine.decInflight(t.ID) + + gid, err := strconv.ParseInt(r.PathValue("gid"), 10, 64) + if err != nil || gid <= 0 { + writeErr(w, 400, "bad goal id") + return + } + node, err := t.Store.GetNode(gid) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if node == nil || node.Kind != db.KindGoal { + writeErr(w, 404, "目标不存在") + return + } + text := goalDTO(node).Text + if err := t.Store.DeleteGoal(gid); err != nil { + writeErr(w, 500, err.Error()) + return + } + t.NotifyGoalDeleted(text) // 记「人删除了该目标:…」触发并唤醒 planner(不复活任务) + writeJSON(w, 200, map[string]bool{"ok": true}) +} diff --git a/server/manager.go b/server/manager.go index 4e03734..24bde8f 100644 --- a/server/manager.go +++ b/server/manager.go @@ -1488,6 +1488,34 @@ func (t *Task) NotifyGoal(texts []string) { t.Notify() } +// NotifyGoalDeleted records that the human deleted a goal (via 总览的目标管理), then +// wakes the planner so the next round spells out which goal was removed. The event +// survives an early-returning terminal round (drain happens after the gate). +func (t *Task) NotifyGoalDeleted(text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + t.trigMu.Lock() + t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal_deleted", Detail: text}) + t.trigMu.Unlock() + t.Notify() +} + +// NotifyGoalEdited records that the human edited a goal (via 总览的目标管理), then wakes +// the planner so the next round spells out the old→new change. The event survives an +// early-returning terminal round (drain happens after the gate). +func (t *Task) NotifyGoalEdited(oldText, newText string) { + oldText, newText = strings.TrimSpace(oldText), strings.TrimSpace(newText) + if newText == "" { + return + } + t.trigMu.Lock() + t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal_edited", OldGoal: oldText, NewGoal: newText}) + t.trigMu.Unlock() + 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. diff --git a/server/server.go b/server/server.go index e39e2ab..6b9c20d 100644 --- a/server/server.go +++ b/server/server.go @@ -665,6 +665,10 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd) mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete) + mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标 + mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务) + mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务) + mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 26f7efc..985b32c 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -6,13 +6,17 @@ import { ActivityIcon, AlertTriangleIcon, BugIcon, + CheckIcon, ClockIcon, CoinsIcon, + ListChecksIcon, + PencilIcon, PlusIcon, RefreshCwIcon, ShieldCheckIcon, TargetIcon, Trash2Icon, + XIcon, } from "lucide-react"; import { StatusBadge } from "@/components/status-badge"; @@ -22,7 +26,7 @@ import { Input } from "@/components/ui/input"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { Progress } from "@/components/ui/progress"; import { api } from "@/lib/api"; -import type { Finding, ModelTokenStat, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types"; +import type { Finding, ModelTokenStat, Stats, Task, TaskGoal, TaskNode, TaskScopeRow } from "@/lib/types"; // 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。 function fmtTokens(n: number): string { @@ -106,6 +110,15 @@ export function OverviewTab({ taskId }: { taskId: string }) { const [scopeErr, setScopeErr] = React.useState(""); // 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。 const [modelTokens, setModelTokens] = React.useState([]); + // 目标管理:目标列表 + 新增表单 + 行内编辑状态。 + const [goals, setGoals] = React.useState([]); + const [goalText, setGoalText] = React.useState(""); + const [goalVuln, setGoalVuln] = React.useState(""); + const [goalBusy, setGoalBusy] = React.useState(false); + const [goalErr, setGoalErr] = React.useState(""); + const [editingGoalId, setEditingGoalId] = React.useState(null); + const [editText, setEditText] = React.useState(""); + const [editVuln, setEditVuln] = React.useState(""); const loadTokens = React.useCallback(async () => { try { @@ -125,6 +138,69 @@ export function OverviewTab({ taskId }: { taskId: string }) { } }, [taskId]); + const loadGoals = React.useCallback(async () => { + try { + const resp = await api.taskGoals(taskId); + setGoals(resp.goals); + } catch { + // 忽略:瞬时错误,下次轮询重试 + } + }, [taskId]); + + const addGoal = async () => { + const text = goalText.trim(); + if (!text) return; + setGoalBusy(true); + setGoalErr(""); + try { + await api.addGoal(taskId, text, goalVuln.trim() || undefined); + setGoalText(""); + setGoalVuln(""); + await loadGoals(); + } catch (e) { + setGoalErr(e instanceof Error ? e.message : "添加失败"); + } finally { + setGoalBusy(false); + } + }; + + const startEditGoal = (g: TaskGoal) => { + setEditingGoalId(g.id); + setEditText(g.text); + setEditVuln(g.vulnclass ?? ""); + }; + + const cancelEditGoal = () => { + setEditingGoalId(null); + setEditText(""); + setEditVuln(""); + }; + + const saveEditGoal = async (g: TaskGoal) => { + const text = editText.trim(); + if (!text) return; + setGoalBusy(true); + setGoalErr(""); + try { + await api.updateGoal(taskId, g.id, text, editVuln.trim() || undefined); + cancelEditGoal(); + await loadGoals(); + } catch (e) { + setGoalErr(e instanceof Error ? e.message : "保存失败"); + } finally { + setGoalBusy(false); + } + }; + + const removeGoal = async (g: TaskGoal) => { + setGoals((prev) => prev.filter((x) => x.id !== g.id)); + try { + await api.deleteGoal(taskId, g.id); + } catch { + await loadGoals(); // 删除失败:重新拉取还原 + } + }; + const addScope = async () => { const value = scopeValueInput.trim(); if (!value) return; @@ -216,15 +292,17 @@ export function OverviewTab({ taskId }: { taskId: string }) { load(); void loadScope(); void loadTokens(); + void loadGoals(); const timer = setInterval(() => { void load(); void loadTokens(); + void loadGoals(); }, 3000); return () => { cancelled = true; clearInterval(timer); }; - }, [taskId, loadScope, loadTokens]); + }, [taskId, loadScope, loadTokens, loadGoals]); const running = intents.filter((i) => i.state === "running"); const open = intents.filter((i) => i.state === "open"); @@ -264,6 +342,128 @@ export function OverviewTab({ taskId }: { taskId: string }) { + {/* 目标管理:查看/新增/修改/删除本任务的探索目标。新增与修改会通知规划者并复活任务, + 删除仅通知规划者(不复活)。目标 = 最终可交付/可核验的结果,不是攻击步骤或侦察动作。 */} + + + + 目标管理 + + (最终可核验的目标,共 {goals.length} 条;新增/修改会通知规划者并复活任务) + + + + + {/* 新增表单 */} +
+ setGoalText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addGoal(); + }} + disabled={goalBusy} + /> + setGoalVuln(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addGoal(); + }} + disabled={goalBusy} + /> + + {goalErr && {goalErr}} +
+ {/* 目标列表 */} + {goals.length > 0 ? ( +
+ {goals.map((g) => + editingGoalId === g.id ? ( +
+ setEditText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void saveEditGoal(g); + if (e.key === "Escape") cancelEditGoal(); + }} + disabled={goalBusy} + autoFocus + /> + setEditVuln(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void saveEditGoal(g); + if (e.key === "Escape") cancelEditGoal(); + }} + disabled={goalBusy} + /> + + +
+ ) : ( +
+ + {g.text} + {g.vulnclass && ( + + {g.vulnclass} + + )} + + +
+ ), + )} +
+ ) : ( +

暂无目标,添加后规划者会据此派发探索意图并判定达成。

+ )} +
+
{coverage && coverage.enabled && coverage.scope_rows > 0 && ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 61d8a21..1fe9234 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -56,6 +56,7 @@ import type { SSTask, Stats, Task, + TaskGoal, TaskLLMResolutions, TaskNode, TaskScopeRow, @@ -256,6 +257,18 @@ export const api = { taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), + // ---- 目标管理(总览)---- + // 本任务全部目标(text/vulnclass/state)。 + taskGoals: (id: string) => get<{ goals: TaskGoal[] }>(`/tasks/${id}/goals`), + // 人工新增目标:写入图谱并通知 planner、复活任务。 + addGoal: (id: string, text: string, vulnclass?: string) => + post(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }), + // 人工修改目标文本(及 vulnclass):通知 planner「由 old 变为 new」、复活任务。 + updateGoal: (id: string, goalId: string, text: string, vulnclass?: string) => + patch(`/tasks/${id}/goals/${goalId}`, { text, vulnclass: vulnclass ?? "" }), + // 人工删除目标(硬删除):通知 planner,删除不复活任务。 + deleteGoal: (id: string, goalId: string) => del<{ ok: boolean }>(`/tasks/${id}/goals/${goalId}`), + // 本任务测试范围列表(含继承自来源任务的范围)。 taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`), // 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。 diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 9842a06..6cdd232 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -299,6 +299,16 @@ export interface TaskNode { inherited?: boolean; } +// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。 +export interface TaskGoal { + id: string; + text: string; + vulnclass?: string; + state: string; // GoalState + origin?: string; + ts: string; +} + // ---- Findings ---- export type Severity = "critical" | "high" | "medium" | "low";