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=不计算/不展示覆盖度
}