diff --git a/db/schema.sql b/db/schema.sql index a3fe24c..908aaaa 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -616,9 +616,16 @@ CREATE TABLE IF NOT EXISTS task_templates ( nkey TEXT NOT NULL UNIQUE, description TEXT NOT NULL, goal TEXT NOT NULL, + -- 预设的任务分类;分类删除时置空(与 tasks.category_id 一致,不阻断)。 + category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL, + -- 预设的任务级拦截/允许规则快照(AssetInterceptRuleInput 数组);应用模板时灌进新任务。 + intercept_rules JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- 补旧库(已发版,加列带 IF NOT EXISTS)。 +ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; +ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]'; CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC); DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates; CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates diff --git a/db/task_templates.go b/db/task_templates.go index 2a5865a..80e3116 100644 --- a/db/task_templates.go +++ b/db/task_templates.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/json" "errors" "fmt" "strings" @@ -22,37 +23,69 @@ var ( ErrTaskTemplateNotFound = errors.New("task template not found") ) -// TaskTemplate is a reusable task description and goal preset. +// TaskTemplate is a reusable task preset (description/goal + optional category +// and task-level intercept/allow rules). type TaskTemplate struct { - ID int64 `json:"id"` - Name string `json:"name"` - NKey string `json:"-"` - Description string `json:"description"` - Goal string `json:"goal"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + NKey string `json:"-"` + Description string `json:"description"` + Goal string `json:"goal"` + CategoryID *int64 `json:"category_id"` + InterceptRules []TaskInterceptRuleInput `json:"intercept_rules"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TaskTemplateInput is the create/update payload after normalization. type TaskTemplateInput struct { - Name string - Description string - Goal string + Name string + Description string + Goal string + CategoryID *int64 + InterceptRules []TaskInterceptRuleInput } -// TaskTemplatePatch changes only fields whose pointers are non-nil. +// TaskTemplatePatch changes only fields flagged as set. Name/Description/Goal use +// non-nil pointers; CategoryID/InterceptRules use explicit Set flags (so a nil +// CategoryID can mean "clear" when SetCategoryID is true). type TaskTemplatePatch struct { - Name *string - Description *string - Goal *string + Name *string + Description *string + Goal *string + CategoryID *int64 + SetCategoryID bool + InterceptRules []TaskInterceptRuleInput + SetInterceptRules bool } -const taskTemplateCols = `id, name, nkey, description, goal, created_at, updated_at` +const taskTemplateCols = `id, name, nkey, description, goal, category_id, intercept_rules, created_at, updated_at` func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) { var t TaskTemplate - err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CreatedAt, &t.UpdatedAt) - return t, err + var rulesRaw []byte + if err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CategoryID, &rulesRaw, &t.CreatedAt, &t.UpdatedAt); err != nil { + return t, err + } + t.InterceptRules = []TaskInterceptRuleInput{} + if len(rulesRaw) > 0 { + if err := json.Unmarshal(rulesRaw, &t.InterceptRules); err != nil { + return t, err + } + if t.InterceptRules == nil { + t.InterceptRules = []TaskInterceptRuleInput{} + } + } + return t, nil +} + +// marshalTemplateRules serializes a template's rule snapshot to JSONB text, +// always producing a JSON array (never null). +func marshalTemplateRules(rules []TaskInterceptRuleInput) ([]byte, error) { + if rules == nil { + rules = []TaskInterceptRuleInput{} + } + return json.Marshal(rules) } // taskTemplateName normalizes display whitespace while preserving the user's case. @@ -83,7 +116,7 @@ func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string } func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) { - if patch.Name == nil && patch.Description == nil && patch.Goal == nil { + if patch.Name == nil && patch.Description == nil && patch.Goal == nil && !patch.SetCategoryID && !patch.SetInterceptRules { return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid) } var nkey *string @@ -133,11 +166,15 @@ func (d *DB) CreateTaskTemplate(in TaskTemplateInput) (*TaskTemplate, error) { if err != nil { return nil, err } + rulesJSON, err := marshalTemplateRules(in.InterceptRules) + if err != nil { + return nil, err + } t, err := scanTaskTemplate(d.QueryRow(` -INSERT INTO task_templates(name, nkey, description, goal) -VALUES ($1,$2,$3,$4) +INSERT INTO task_templates(name, nkey, description, goal, category_id, intercept_rules) +VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (nkey) DO NOTHING -RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal)) +RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal, in.CategoryID, rulesJSON)) if err == sql.ErrNoRows { return nil, ErrTaskTemplateNameConflict } @@ -196,17 +233,25 @@ func (d *DB) PatchTaskTemplate(id int64, patch TaskTemplatePatch) (*TaskTemplate if err != nil { return nil, err } + rulesJSON, err := marshalTemplateRules(patch.InterceptRules) + if err != nil { + return nil, err + } t, err := scanTaskTemplate(d.QueryRow(`UPDATE task_templates SET name=CASE WHEN $2 THEN $3::text ELSE name END, nkey=CASE WHEN $2 THEN $4::text ELSE nkey END, description=CASE WHEN $5 THEN $6::text ELSE description END, - goal=CASE WHEN $7 THEN $8::text ELSE goal END + goal=CASE WHEN $7 THEN $8::text ELSE goal END, + category_id=CASE WHEN $9 THEN $10::bigint ELSE category_id END, + intercept_rules=CASE WHEN $11 THEN $12::jsonb ELSE intercept_rules END WHERE id=$1 RETURNING `+taskTemplateCols, id, patch.Name != nil, patch.Name, nkey, patch.Description != nil, patch.Description, patch.Goal != nil, patch.Goal, + patch.SetCategoryID, patch.CategoryID, + patch.SetInterceptRules, rulesJSON, )) if err == sql.ErrNoRows { return nil, ErrTaskTemplateNotFound diff --git a/server/task_templates.go b/server/task_templates.go index 2e728b1..7125177 100644 --- a/server/task_templates.go +++ b/server/task_templates.go @@ -1,8 +1,10 @@ package server import ( + "encoding/json" "errors" "fmt" + "io" "net/http" "strings" "unicode/utf8" @@ -14,24 +16,44 @@ const maxTaskTemplateRequestBytes = 512 << 10 // taskTemplateRequest uses pointers so PATCH can distinguish omitted fields from // explicit empty values. Empty values are still rejected by the DB validator. +// category_id / intercept_rules presence is detected via the raw key set (see +// decodeTaskTemplateRequest) so a PATCH can clear category to null. type taskTemplateRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` - Goal *string `json:"goal"` + Name *string `json:"name"` + Description *string `json:"description"` + Goal *string `json:"goal"` + CategoryID *int64 `json:"category_id"` + InterceptRules []taskInterceptRuleReq `json:"intercept_rules"` } -func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) bool { +// decodeTaskTemplateRequest decodes the body into req and returns the set of +// top-level keys present in the JSON (for PATCH presence detection). +func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) (map[string]struct{}, bool) { r.Body = http.MaxBytesReader(w, r.Body, maxTaskTemplateRequestBytes) - if err := decode(r, req); err != nil { + body, err := io.ReadAll(r.Body) + if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大") } else { writeErr(w, http.StatusBadRequest, err.Error()) } - return false + return nil, false } - return true + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return nil, false + } + if err := json.Unmarshal(body, req); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return nil, false + } + present := make(map[string]struct{}, len(raw)) + for k := range raw { + present[k] = struct{}{} + } + return present, true } func validateTaskTemplateRequest(req taskTemplateRequest) error { @@ -91,17 +113,24 @@ func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) { return } var req taskTemplateRequest - if !decodeTaskTemplateRequest(w, r, &req) { + if _, ok := decodeTaskTemplateRequest(w, r, &req); !ok { return } if err := validateTaskTemplateRequest(req); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } + rules, err := buildTaskInterceptRules(req.InterceptRules) + if err != nil { + writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error()) + return + } template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{ - Name: stringValue(req.Name), - Description: stringValue(req.Description), - Goal: stringValue(req.Goal), + Name: stringValue(req.Name), + Description: stringValue(req.Description), + Goal: stringValue(req.Goal), + CategoryID: req.CategoryID, + InterceptRules: rules, }) if err != nil { writeTaskTemplateErr(w, err) @@ -121,20 +150,35 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) { return } var req taskTemplateRequest - if !decodeTaskTemplateRequest(w, r, &req) { + present, ok := decodeTaskTemplateRequest(w, r, &req) + if !ok { return } if err := validateTaskTemplateRequest(req); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } - if req.Name == nil && req.Description == nil && req.Goal == nil { - writeErr(w, http.StatusBadRequest, "至少需要提供 name、description 或 goal") + _, catPresent := present["category_id"] + _, rulesPresent := present["intercept_rules"] + if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent { + writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、category_id 或 intercept_rules") return } - template, err := pg.PatchTaskTemplate(id, db.TaskTemplatePatch{ - Name: req.Name, Description: req.Description, Goal: req.Goal, - }) + patch := db.TaskTemplatePatch{Name: req.Name, Description: req.Description, Goal: req.Goal} + if catPresent { + patch.SetCategoryID = true + patch.CategoryID = req.CategoryID + } + if rulesPresent { + rules, err := buildTaskInterceptRules(req.InterceptRules) + if err != nil { + writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error()) + return + } + patch.SetInterceptRules = true + patch.InterceptRules = rules + } + template, err := pg.PatchTaskTemplate(id, patch) if err != nil { writeTaskTemplateErr(w, err) return diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 4dcb0bf..1cd442d 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -44,6 +44,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor"; import { StatusBadge } from "@/components/status-badge"; import { TablePagination } from "@/components/table-pagination"; import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain"; @@ -98,7 +99,6 @@ import { import { Input } from "@/components/ui/input"; import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item"; import { Label } from "@/components/ui/label"; -import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { Progress } from "@/components/ui/progress"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -122,7 +122,6 @@ import { api } from "@/lib/api"; import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference"; import type { - AssetInterceptKind, AssetInterceptRuleInput, ChatAttachment, Company, @@ -3035,92 +3034,6 @@ function CategoryManagementSheet({ ); } -const TASK_INTERCEPT_KIND_OPTIONS: { value: AssetInterceptKind; label: string; placeholder: string }[] = [ - { value: "exact_domain", label: "域名(全等)", placeholder: "example.gov.cn" }, - { value: "exact_ip", label: "IP(全等)", placeholder: "203.0.113.10" }, - { value: "exact_url", label: "URL(全等)", placeholder: "https://example.com/login" }, - { value: "fuzzy_domain", label: "域名(模糊)", placeholder: ".gov.cn" }, - { value: "fuzzy_ip", label: "IP(模糊)", placeholder: "203.0.113." }, - { value: "fuzzy_url", label: "URL(模糊)", placeholder: "/admin" }, - { value: "cidr", label: "CIDR 网段", placeholder: "192.168.0.0/16" }, -]; - -// TaskInterceptRulesField 是创建任务时录入「任务级资产拦截规则」的多行编辑区。 -// 规则只对本任务生效、不进全局表;与全局规则合并后在 agent 执行时匹配。 -function TaskInterceptRulesField({ - value, - onChange, -}: { - value: AssetInterceptRuleInput[]; - onChange: (v: AssetInterceptRuleInput[]) => void; -}) { - function update(i: number, patch: Partial) { - onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); - } - function remove(i: number) { - onChange(value.filter((_, idx) => idx !== i)); - } - function add() { - onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]); - } - return ( -
- {value.map((r, i) => { - const ph = TASK_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? ""; - return ( -
- update(i, { action: e.target.value as "block" | "allow" })} - > - 拦截 - 允许 - - update(i, { kind: e.target.value as AssetInterceptKind })} - > - {TASK_INTERCEPT_KIND_OPTIONS.map((o) => ( - - {o.label} - - ))} - - update(i, { pattern: e.target.value })} - /> - update(i, { note: e.target.value })} - /> - -
- ); - })} - -
- ); -} - function CreateTaskSheet({ tasks, categories, @@ -3268,11 +3181,15 @@ function CreateTaskSheet({ { setDescription(template.description); setGoal(template.goal); + setCategoryID(template.category_id ?? undefined); + setInterceptRules(template.intercept_rules ?? []); setUploadCount(0); }} portalContainer={sheetContentRef} @@ -3370,7 +3287,7 @@ function CreateTaskSheet({ 任务级资产拦截 / 允许规则(可选) - + 仅对本任务生效,不写入全局规则。判定顺序:先按「拦截」规则(含全局)匹配,命中即禁止测试;未命中且本任务配置了「允许」规则时,须命中某条允许规则才放行,否则同样不允许测试;未配置任何允许规则则不启用白名单。 diff --git a/web/src/components/asset-intercept-rules-editor.tsx b/web/src/components/asset-intercept-rules-editor.tsx new file mode 100644 index 0000000..6d58eea --- /dev/null +++ b/web/src/components/asset-intercept-rules-editor.tsx @@ -0,0 +1,103 @@ +"use client"; + +import * as React from "react"; + +import { PlusIcon, Trash2Icon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import type { AssetInterceptKind, AssetInterceptRuleInput } from "@/lib/types"; + +// 用 NativeSelect(原生 update(i, { pattern: e.target.value })} + /> + update(i, { note: e.target.value })} + /> + + + ); + })} + + + ); +} diff --git a/web/src/components/task-template-controls.tsx b/web/src/components/task-template-controls.tsx index 9b6b536..d3cd13f 100644 --- a/web/src/components/task-template-controls.tsx +++ b/web/src/components/task-template-controls.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; +import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor"; import { AlertDialog, AlertDialogAction, @@ -26,37 +27,51 @@ import { } from "@/components/ui/combobox"; import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; -import type { TaskTemplate } from "@/lib/types"; +import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types"; import { cn } from "@/lib/utils"; interface TemplateDraft { name: string; description: string; goal: string; + categoryID: number | null; + interceptRules: AssetInterceptRuleInput[]; } +// TemplateSeed 是「另存为模板」时从创建表单带入的初值。 +type TemplateSeed = Pick; + interface TaskTemplateManagerProps { open: boolean; onOpenChange: (open: boolean) => void; templates: TaskTemplate[]; - seed: Pick | null; + seed: TemplateSeed | null; onCreated: (template: TaskTemplate) => void; onUpdated: (template: TaskTemplate) => void; onDeleted: (id: number) => void; } -const emptyDraft = (): TemplateDraft => ({ name: "", description: "", goal: "" }); +const emptyDraft = (): TemplateDraft => ({ + name: "", + description: "", + goal: "", + categoryID: null, + interceptRules: [], +}); function templateDraft(template: TaskTemplate): TemplateDraft { return { name: template.name, description: template.description, goal: template.goal, + categoryID: template.category_id ?? null, + interceptRules: template.intercept_rules ?? [], }; } @@ -74,13 +89,28 @@ function TaskTemplateManager({ const [saving, setSaving] = React.useState(false); const [deleteOpen, setDeleteOpen] = React.useState(false); const [deleting, setDeleting] = React.useState(false); + const [categories, setCategories] = React.useState([]); const wasOpen = React.useRef(false); + React.useEffect(() => { + if (!open) return; + api + .taskCategories() + .then(setCategories) + .catch(() => setCategories([])); + }, [open]); + React.useEffect(() => { if (open && !wasOpen.current) { if (seed) { setSelectedID(null); - setDraft({ name: "", description: seed.description, goal: seed.goal }); + setDraft({ + name: "", + description: seed.description, + goal: seed.goal, + categoryID: seed.categoryID, + interceptRules: seed.interceptRules, + }); } else if (templates[0]) { setSelectedID(templates[0].id); setDraft(templateDraft(templates[0])); @@ -102,15 +132,23 @@ function TaskTemplateManager({ setDraft(emptyDraft()); }; - const updateDraft = (field: keyof TemplateDraft, value: string) => { + const updateDraft = (field: "name" | "description" | "goal", value: string) => { setDraft((current) => ({ ...current, [field]: value })); }; + const patchDraft = (patch: Partial) => { + setDraft((current) => ({ ...current, ...patch })); + }; + async function save() { const input = { name: draft.name.trim(), description: draft.description.trim(), goal: draft.goal.trim(), + category_id: draft.categoryID, + intercept_rules: draft.interceptRules + .map((r) => ({ ...r, pattern: r.pattern.trim() })) + .filter((r) => r.pattern !== ""), }; if (!input.name || !input.description || !input.goal) { toast.error("请填写模板名称、描述和目标"); @@ -169,7 +207,7 @@ function TaskTemplateManager({ 任务模板管理 - 模板只保存描述与目标;修改不会影响已经创建的任务。 + 模板保存描述、目标、分类与任务级拦截/允许规则;修改不会影响已经创建的任务。
@@ -231,6 +269,35 @@ function TaskTemplateManager({ onChange={(event) => updateDraft("goal", event.target.value)} /> + + 任务分类 + + patchDraft({ categoryID: event.target.value === "" ? null : Number(event.target.value) }) + } + > + 未分类 + {categories.map((c) => ( + + {c.name} + + ))} + + 应用模板时预填此分类(可再改)。 + + + 任务级拦截 / 允许规则 + patchDraft({ interceptRules: rules })} + /> + + 应用模板时预填这些任务级规则(拦截/允许,仅对新任务生效,不进全局)。 + +
@@ -280,6 +347,8 @@ function TaskTemplateManager({ interface TaskTemplateControlsProps { description: string; goal: string; + categoryID?: number; + interceptRules?: AssetInterceptRuleInput[]; selectedTemplateID: number | null; onSelectedTemplateIDChange: (id: number | null) => void; onApply: (template: TaskTemplate) => void; @@ -289,6 +358,8 @@ interface TaskTemplateControlsProps { export function TaskTemplateControls({ description, goal, + categoryID, + interceptRules, selectedTemplateID, onSelectedTemplateIDChange, onApply, @@ -299,7 +370,7 @@ export function TaskTemplateControls({ const [templateInputValue, setTemplateInputValue] = React.useState(""); const [pendingTemplate, setPendingTemplate] = React.useState(null); const [managerOpen, setManagerOpen] = React.useState(false); - const [managerSeed, setManagerSeed] = React.useState | null>(null); + const [managerSeed, setManagerSeed] = React.useState(null); const loadTemplates = React.useCallback(async () => { setLoading(true); @@ -348,7 +419,7 @@ export function TaskTemplateControls({ applyTemplate(template); }; - const openManager = (seed: Pick | null) => { + const openManager = (seed: TemplateSeed | null) => { setManagerSeed(seed); setManagerOpen(true); }; @@ -380,7 +451,14 @@ export function TaskTemplateControls({ variant="ghost" size="sm" disabled={!description.trim() || !goal.trim()} - onClick={() => openManager({ description, goal })} + onClick={() => + openManager({ + description, + goal, + categoryID: categoryID ?? null, + interceptRules: interceptRules ?? [], + }) + } > 另存为模板 @@ -420,7 +498,7 @@ export function TaskTemplateControls({ - 选择后会复制模板的描述和目标,不与模板保持关联。 + 选择后会复制模板的描述、目标、分类与任务级规则,不与模板保持关联。 get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)), - createTaskTemplate: (input: Pick) => - post("/task-templates", input), - updateTaskTemplate: (id: number, input: Partial>) => - patch(`/task-templates/${id}`, input), + createTaskTemplate: ( + input: Pick, + ) => post("/task-templates", input), + updateTaskTemplate: ( + id: number, + input: Partial>, + ) => patch(`/task-templates/${id}`, input), deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`), updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) => put<{ diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 9fe7fa5..165dd52 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -53,6 +53,8 @@ export interface TaskTemplate { name: string; description: string; goal: string; + category_id?: number | null; // 预设分类;null/缺省=无 + intercept_rules?: AssetInterceptRuleInput[]; // 预设的任务级拦截/允许规则 created_at: string; updated_at: string; }