diff --git a/agent/blackboard_inheritance_test.go b/agent/blackboard_inheritance_test.go index fcbcb6f..ad4fe4b 100644 --- a/agent/blackboard_inheritance_test.go +++ b/agent/blackboard_inheritance_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "time" @@ -51,6 +52,12 @@ func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) { if added != len(inputs) || skipped != 0 || invalid != 0 || len(scopeErrors) != 0 { t.Fatalf("add company scope: added=%d skipped=%d invalid=%d errors=%v", added, skipped, invalid, scopeErrors) } + assets := d.Assets() + assetID, err := assets.UpsertRootDomain(db.UpsertRootDomainReq{Domain: domain}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = assets.DeleteByIDs([]int64{assetID}) }) task, err := d.CreateTaskWithOptions("company context", "read configured scope", db.TaskCreateOptions{ CompanyIDs: []int64{companyID}, @@ -62,7 +69,14 @@ func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) { tools := NewToolSet(d.Exploration(task.ExplorationID), "planner") tools.SetTaskID(task.ID) - tools.SetAssetStore(d.Assets(), companies) + tools.SetAssetStore(assets, companies) + linkedAssets, err := assets.QueryByTask(task.ID, "root_domain", 10, 0) + if err != nil || len(linkedAssets) != 1 || linkedAssets[0].ID != assetID { + t.Fatalf("company asset was not linked to task: assets=%+v err=%v", linkedAssets, err) + } + if linkedAssets[0].TaskSource != "company" || !strings.Contains(linkedAssets[0].TaskSourceSummary, companiesName(t, companies, companyID)) { + t.Fatalf("company asset provenance missing: %+v", linkedAssets[0]) + } overview := tools.graphOverviewData() coverage, ok := overview["coverage"].(map[string]any) if !ok { @@ -89,6 +103,95 @@ func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) { if !ok || len(keywords) != 1 || keywords[0] != keyword { t.Fatalf("company keywords missing: %#v", scopeRows[0]["company_keywords"]) } + hosts, ok := coverage["hosts"].([]string) + if !ok || !containsString(hosts, domain) { + t.Fatalf("company asset host missing from agent context: %#v", coverage["hosts"]) + } + untested := callReadJSON(t, tools.listUntestedAssets(), `{"type":"root_domain","page":1,"page_size":10}`) + if !strings.Contains(fmt.Sprint(untested), domain) { + t.Fatalf("company asset missing from untested backlog: %#v", untested) + } + byTask := callReadJSON(t, tools.listAssets(), fmt.Sprintf( + `{"dsl":"task_id==%d","type":"root_domain","limit":10}`, task.ID, + )) + if !strings.Contains(fmt.Sprint(byTask), domain) { + t.Fatalf("company asset missing from task-scoped agent query: %#v", byTask) + } + byCompany := callReadJSON(t, tools.listAssets(), fmt.Sprintf( + `{"dsl":"company_id==%d","type":"root_domain","limit":10}`, companyID, + )) + if !strings.Contains(fmt.Sprint(byCompany), domain) { + t.Fatalf("company asset missing from company-scoped agent query: %#v", byCompany) + } + assetJSON, _ := json.Marshal(assetID) + intentID, err := tools.addOneIntent(intentItem{ + Summary: "test associated company asset", + AssetIDs: []json.RawMessage{assetJSON}, + }) + if err != nil { + t.Fatal(err) + } + workerAssets, err := assets.IntentAssets(task.ID) + if err != nil || len(workerAssets) != 1 || workerAssets[0].IntentID != intentID || workerAssets[0].AssetID != assetID { + t.Fatalf("worker target did not retain company asset: assets=%+v err=%v", workerAssets, err) + } + + coverageDisabled := false + disabledTask, err := d.CreateTaskWithOptions("company context without coverage", "still expose company assets", db.TaskCreateOptions{ + CompanyIDs: []int64{companyID}, CoverageEnabled: &coverageDisabled, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(disabledTask.ID) }) + disabledTools := NewToolSet(d.Exploration(disabledTask.ExplorationID), "planner") + disabledTools.SetTaskID(disabledTask.ID) + disabledTools.SetCoverageEnabled(false) + disabledTools.SetAssetStore(assets, companies) + disabledOverview := disabledTools.graphOverviewData() + disabledCoverage, ok := disabledOverview["coverage"].(map[string]any) + if !ok { + t.Fatalf("coverage-disabled task lost asset context: %#v", disabledOverview["coverage"]) + } + disabledHosts, ok := disabledCoverage["hosts"].([]string) + if !ok || !containsString(disabledHosts, domain) { + t.Fatalf("coverage-disabled task lost company asset host: %#v", disabledCoverage["hosts"]) + } + if _, exists := disabledCoverage["denominator"]; exists { + t.Fatalf("coverage-disabled task unexpectedly exposed metrics: %#v", disabledCoverage) + } + if linked, err := assets.QueryByTask(disabledTask.ID, "root_domain", 10, 0); err != nil || len(linked) != 1 || linked[0].ID != assetID { + t.Fatalf("coverage-disabled task asset link=%+v err=%v", linked, err) + } + disabledIntentID, err := disabledTools.addOneIntent(intentItem{ + Summary: "test associated company asset without coverage metrics", + AssetIDs: []json.RawMessage{assetJSON}, + }) + if err != nil { + t.Fatal(err) + } + disabledWorkerAssets, err := assets.IntentAssets(disabledTask.ID) + if err != nil || len(disabledWorkerAssets) != 1 || disabledWorkerAssets[0].IntentID != disabledIntentID || disabledWorkerAssets[0].AssetID != assetID { + t.Fatalf("coverage-disabled worker target=%+v err=%v", disabledWorkerAssets, err) + } +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func companiesName(t *testing.T, companies *db.CompanyStore, companyID int64) string { + t.Helper() + company, err := companies.GetCompany(companyID) + if err != nil || company == nil { + t.Fatalf("company %d: company=%+v err=%v", companyID, company, err) + } + return company.Name } func TestBlackboardToolsReadDirectSources(t *testing.T) { diff --git a/db/companies_test.go b/db/companies_test.go index f1592c8..465966d 100644 --- a/db/companies_test.go +++ b/db/companies_test.go @@ -177,9 +177,13 @@ func TestCompanyScopeInvalid(t *testing.T) { } defer cleanupCompany(d, id) - // TLD-only domains and overly broad CIDRs should be rejected - lines := []string{"com", "1.2.3.4/8"} // rejected - added, _, invalid, _ := cs.AddScope(id, lines, "test") + // Explicitly typed TLD-only domains and overly broad CIDRs should be + // rejected. Untyped plain text is intentionally classified as a keyword. + inputs := []ScopeInput{ + {Kind: "domain", Value: "com"}, + {Kind: "cidr", Value: "1.2.3.4/8"}, + } + added, _, invalid, _ := cs.AddScopeInputs(id, inputs, "test") if added != 0 { t.Errorf("want 0 added for invalid lines, got %d", added) } diff --git a/db/tasks.go b/db/tasks.go index 115ed0c..f658305 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -89,10 +89,12 @@ const MinPlanHeartbeatSeconds = 600 const MaxTaskSourceCount = 8 // MaxTaskCompanyCount bounds the number of company asset scopes attached to a -// task. Company scopes are prompt context, so an unbounded list would inflate -// every planner and worker call. +// task. Company scopes are prompt context, and their currently attributed +// assets are snapshotted into the task at creation time. const MaxTaskCompanyCount = 32 +const taskCompanyAssetSource = "company" + var ( ErrTaskCompanyIDsInvalid = errors.New("invalid task company ids") ErrTaskCompanyNotFound = errors.New("task company not found") @@ -248,6 +250,11 @@ func insertTaskCompanies(tx *sql.Tx, taskID int64, companyIDs []int64) error { if len(companyIDs) == 0 { return nil } + // Company scope edits rebuild assets.company_id. Serialize the creation-time + // snapshot with those edits so the task sees one committed attribution state. + if err := lockCompanyScopeMutation(tx); err != nil { + return err + } var inserted int err := tx.QueryRow(` WITH requested(company_id, position) AS ( @@ -268,6 +275,32 @@ SELECT count(*) FROM inserted`, taskID, companyIDs).Scan(&inserted) if inserted != len(companyIDs) { return fmt.Errorf("%w: one or more companies do not exist", ErrTaskCompanyNotFound) } + + // Updating task_ids fires sync_task_asset_links, which first creates generic + // source rows. The provenance upsert must therefore run afterwards so the + // company name and creation-time reason remain visible to operators. + if _, err := tx.Exec(` +UPDATE assets +SET task_ids=CASE + WHEN $1=ANY(task_ids) THEN task_ids + ELSE array_append(task_ids, $1) +END +WHERE company_id=ANY($2::bigint[])`, taskID, companyIDs); err != nil { + return err + } + if _, err := tx.Exec(` +INSERT INTO task_asset_links(task_id, asset_id, source, source_summary) +SELECT $1, asset.id, $3, '任务创建时关联企业:' || company.name +FROM assets asset +JOIN companies company ON company.id=asset.company_id +WHERE asset.company_id=ANY($2::bigint[]) + AND $1=ANY(asset.task_ids) +ON CONFLICT (task_id, asset_id) DO UPDATE +SET source=EXCLUDED.source, + source_summary=EXCLUDED.source_summary, + source_node_id=NULL`, taskID, companyIDs, taskCompanyAssetSource); err != nil { + return err + } return nil } diff --git a/db/tasks_test.go b/db/tasks_test.go index ef5dc26..ae63e01 100644 --- a/db/tasks_test.go +++ b/db/tasks_test.go @@ -509,10 +509,36 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) { if err != nil { t.Fatal(err) } + emptyCompany, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Task Empty Company %d", suffix), "") + if err != nil { + t.Fatal(err) + } t.Cleanup(func() { - _, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2)`, companyA, companyB) + _, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2,$3)`, companyA, companyB, emptyCompany) }) + existingTask, err := d.CreateTask("existing company asset owner", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(existingTask.ID) }) + var companyAssetA, companyAssetB int64 + domainA := fmt.Sprintf("task-company-a-%d.example.test", suffix) + domainB := fmt.Sprintf("task-company-b-%d.example.test", suffix) + if err := d.QueryRow(` +INSERT INTO assets(type, domain, root_domain, company_id, company_source, task_ids) +VALUES ('root_domain',$1,$1,$2,'explicit',ARRAY[$3]::bigint[]) +RETURNING id`, domainA, companyA, existingTask.ID).Scan(&companyAssetA); err != nil { + t.Fatal(err) + } + if err := d.QueryRow(` +INSERT INTO assets(type, domain, root_domain, company_id, company_source) +VALUES ('root_domain',$1,$1,$2,'explicit') +RETURNING id`, domainB, companyB).Scan(&companyAssetB); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{companyAssetA, companyAssetB}) }) + task, err := d.CreateTaskWithOptions("company-scoped task", "use company scope", TaskCreateOptions{ CompanyIDs: []int64{companyA, companyB}, }) @@ -537,6 +563,57 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) { if scopeCount != 2 { t.Fatalf("company task scope rows=%d want 2", scopeCount) } + assets, err := d.Assets().QueryByTask(task.ID, "root_domain", 10, 0) + if err != nil { + t.Fatal(err) + } + if len(assets) != 2 { + t.Fatalf("company task assets=%d want 2: %+v", len(assets), assets) + } + assetByID := make(map[int64]*Asset, len(assets)) + for _, asset := range assets { + assetByID[asset.ID] = asset + } + for assetID, companyName := range map[int64]string{ + companyAssetA: fmt.Sprintf("Task Company A %d", suffix), + companyAssetB: fmt.Sprintf("Task Company B %d", suffix), + } { + asset := assetByID[assetID] + if asset == nil { + t.Errorf("company asset %d missing from task", assetID) + continue + } + if asset.TaskSource != taskCompanyAssetSource || asset.TaskSourceSummary != "任务创建时关联企业:"+companyName { + t.Errorf("asset %d provenance=%q/%q", assetID, asset.TaskSource, asset.TaskSourceSummary) + } + } + var existingAssociation bool + if err := d.QueryRow(`SELECT $1=ANY(task_ids) FROM assets WHERE id=$2`, existingTask.ID, companyAssetA).Scan(&existingAssociation); err != nil { + t.Fatal(err) + } + if !existingAssociation { + t.Fatal("company snapshot removed the asset's existing task association") + } + var existingSource string + if err := d.QueryRow(`SELECT source FROM task_asset_links WHERE task_id=$1 AND asset_id=$2`, existingTask.ID, companyAssetA).Scan(&existingSource); err != nil { + t.Fatal(err) + } + if existingSource == taskCompanyAssetSource { + t.Fatalf("company snapshot overwrote another task's provenance: %q", existingSource) + } + + emptyTask, err := d.CreateTaskWithOptions("empty company scope", "no current assets", TaskCreateOptions{ + CompanyIDs: []int64{emptyCompany}, + }) + if err != nil { + t.Fatal(err) + } + if assets, err := d.Assets().QueryByTask(emptyTask.ID, "", 10, 0); err != nil || len(assets) != 0 { + t.Fatalf("empty company task assets=%+v err=%v", assets, err) + } + if err := d.DeleteTask(emptyTask.ID); err != nil { + t.Fatal(err) + } duplicateTask, err := d.CreateTaskWithOptions("duplicate company scope", "deduplicate", TaskCreateOptions{ CompanyIDs: []int64{companyA, companyA, companyB, companyA}, diff --git a/server/server.go b/server/server.go index 999fcb6..266c754 100644 --- a/server/server.go +++ b/server/server.go @@ -1363,7 +1363,7 @@ type createTaskReq struct { LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置 LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效 SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务 - CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业资产范围;不复制资产或强制生成意图 + CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图 TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时 PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index f7050e2..1996f79 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -2613,8 +2613,8 @@ function CreateTaskSheet({ portalContainer={sheetContentRef} /> - 将所选企业的域名、IP、CIDR、ICP 和企业关键词作为 Agent - 可选范围上下文;不会自动生成意图或强制改变执行目标。 + 创建任务时会将所选企业当前已有资产加入“测试资产”,并将域名、IP、CIDR、ICP 和企业关键词提供给 Agent + 作为范围上下文;不会自动生成意图或强制改变执行目标。 diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index b7623ff..b565110 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -39,7 +39,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; -import { cn } from "@/lib/utils"; +import { cn, copyText } from "@/lib/utils"; function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN", { @@ -199,12 +199,12 @@ function HttpCodeBlock({ raw }: { raw: string }) { }, [copied]); const copyPacket = async () => { - try { - await navigator.clipboard.writeText(value); + const ok = await copyText(value); + if (ok) { setCopied(true); - } catch { - toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); + return; } + toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); }; const renderLine = (line: string, index: number) => { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 6e9b4fd..71ccc50 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -42,9 +42,35 @@ const mockCompanies = structuredClone(D.companies); const mockAssets = structuredClone(D.assets); const mockTaskAssetIDs = new Map(D.tasks.map((task, index) => [task.id, index + 1])); const mockTaskScopes = new Map(); +type MockTaskAssetSource = Pick; +const mockTaskAssetSources = new Map(); let nextMockTaskAssetID = D.tasks.length + 1; let mockActiveTask = D.ACTIVE_TASK; +function mockTaskAssetSourceKey(taskID: string, assetID: number): string { + return JSON.stringify([taskID, assetID]); +} + +function setMockTaskAssetSource(taskID: string, assetID: number, source: MockTaskAssetSource) { + mockTaskAssetSources.set(mockTaskAssetSourceKey(taskID, assetID), source); +} + +function mockAssetForTask(taskID: string, asset: Asset): Asset { + const source = mockTaskAssetSources.get(mockTaskAssetSourceKey(taskID, asset.id)); + return source ? { ...asset, ...source } : asset; +} + +function deleteMockTaskAssetSources(taskID: string, assetID?: number) { + if (assetID !== undefined) { + mockTaskAssetSources.delete(mockTaskAssetSourceKey(taskID, assetID)); + return; + } + for (const key of mockTaskAssetSources.keys()) { + const [linkedTaskID] = JSON.parse(key) as [string, number]; + if (linkedTaskID === taskID) mockTaskAssetSources.delete(key); + } +} + function mockTaskAssetID(taskID: string): number | undefined { const numeric = Number(taskID); if (Number.isInteger(numeric) && numeric > 0) return numeric; @@ -289,7 +315,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const now = new Date(); const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])]; const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])]; - const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])]; + const companyIDs = [...new Set((b.company_ids as number[] | undefined) ?? [])]; + if (companyIDs.some((companyID) => !mockCompanies.some((company) => company.id === companyID))) { + throw new Error("关联企业不存在或无效"); + } const categoryID = typeof b.category_id === "number" ? b.category_id : undefined; const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID); if (categoryID !== undefined && !category) throw new Error("任务分类不存在"); @@ -318,7 +347,19 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re source_task_ids: sourceTaskIDs, company_ids: companyIDs, }; - mockTaskAssetIDs.set(id, nextMockTaskAssetID++); + const numericTaskID = nextMockTaskAssetID++; + mockTaskAssetIDs.set(id, numericTaskID); + const selectedCompanies = new Set(companyIDs); + for (const asset of mockAssets) { + if (asset.company_id === undefined || !selectedCompanies.has(asset.company_id)) continue; + if (!asset.task_ids.includes(numericTaskID)) asset.task_ids.push(numericTaskID); + const company = mockCompanies.find((candidate) => candidate.id === asset.company_id); + setMockTaskAssetSource(id, asset.id, { + task_source: "company", + task_source_summary: `任务创建时关联企业:${company?.name ?? `#${asset.company_id}`}`, + task_source_node_id: undefined, + }); + } for (const item of mockTasks) item.active = false; mockTasks.unshift(created); mockActiveTask = id; @@ -462,9 +503,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") { const id = seg[1]; + const numericTaskID = mockTaskAssetID(id); const index = mockTasks.findIndex((item) => item.id === id); if (index >= 0) mockTasks.splice(index, 1); mockTaskAssetIDs.delete(id); + deleteMockTaskAssetSources(id); + if (numericTaskID !== undefined) { + for (const asset of mockAssets) asset.task_ids = asset.task_ids.filter((taskID) => taskID !== numericTaskID); + } let findingsDeleted = 0; if (b.delete_findings) { @@ -701,7 +747,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re }); const limit = Number(q.get("limit") ?? 50); const offset = Number(q.get("offset") ?? 0); - const page = list.slice(offset, offset + limit); + const page = list.slice(offset, offset + limit).map((asset) => (taskID ? mockAssetForTask(taskID, asset) : asset)); return { count: page.length, total: list.length, assets: page }; } if (path === "/assets" && m === "DELETE") { @@ -792,9 +838,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re asset.task_ids.push(numericTaskID); mutation.assets_linked++; } - asset.task_source = "manual"; - asset.task_source_summary = "用户在测试资产页手工新增"; - asset.task_source_node_id = undefined; + setMockTaskAssetSource(seg[1], asset.id, { + task_source: "manual", + task_source_summary: "用户在测试资产页手工新增", + task_source_node_id: undefined, + }); } mockTaskScopes.set(seg[1], currentScopes); return mutation; @@ -812,9 +860,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re asset.task_ids.push(numericTaskID); mutation.attached++; } - asset.task_source = "manual"; - asset.task_source_summary = sourceSummary; - asset.task_source_node_id = undefined; + setMockTaskAssetSource(seg[1], asset.id, { + task_source: "manual", + task_source_summary: sourceSummary, + task_source_node_id: undefined, + }); } return mutation; } @@ -824,11 +874,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (numericTaskID === undefined || !asset) throw new Error("任务或资产不存在"); if (!asset.task_ids.includes(numericTaskID)) throw new Error("资产未关联当前任务"); asset.task_ids = asset.task_ids.filter((id) => id !== numericTaskID); - if (asset.task_ids.length === 0) { - asset.task_source = undefined; - asset.task_source_summary = undefined; - asset.task_source_node_id = undefined; - } + deleteMockTaskAssetSources(seg[1], asset.id); return { detached: asset.id }; } if (seg[0] === "tasks" && seg[2] === "intent-assets" && seg.length === 3 && m === "GET") { @@ -843,8 +889,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } const sourceTaskID = mockTaskAssetID(seg[1]) ?? 0; const assets: IntentAsset[] = mappings.flatMap((mapping) => { - const asset = mockAssets.find((item) => item.id === mapping.assetID); - if (!asset) return []; + const storedAsset = mockAssets.find((item) => item.id === mapping.assetID); + if (!storedAsset) return []; + const asset = mockAssetForTask(seg[1], storedAsset); return [ { intent_id: mapping.intentID, diff --git a/web/src/lib/task-assets.ts b/web/src/lib/task-assets.ts index 1bfccc7..1981c15 100644 --- a/web/src/lib/task-assets.ts +++ b/web/src/lib/task-assets.ts @@ -13,6 +13,7 @@ const TASK_ASSET_SOURCE_LABELS: Record = { agent: "Agent 发现", anchor: "黑板锚点", api: "资产 API", + company: "企业关联", legacy: "历史关联", manual: "人工加入", system: "系统关联", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 59c4b3c..8b0208d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -35,7 +35,7 @@ export interface Task { llm_failover_state?: "default" | "ready" | "chain_exhausted" | string; llm_failover_reason?: string; source_task_ids?: string[]; // directly related tasks inherited as read-only context - company_ids?: number[]; // associated company asset scopes available as task context + company_ids?: number[]; // associated company scopes; current company assets join the task at creation coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度 }