diff --git a/db/asset_dsl.go b/db/asset_dsl.go index 26c05d9..a8fcb06 100644 --- a/db/asset_dsl.go +++ b/db/asset_dsl.go @@ -54,6 +54,7 @@ var knownArrayFields = map[string]string{ // knownNumericFields maps DSL field name → SQL column name (integer). var knownNumericFields = map[string]string{ + "id": "id", "port": "port", "status_code": "status_code", "status": "status_code", diff --git a/db/intelligence.go b/db/intelligence.go index 54b7f0e..562b997 100644 --- a/db/intelligence.go +++ b/db/intelligence.go @@ -30,13 +30,14 @@ type IntelligenceInput struct { } type Intelligence struct { IntelligenceInput - ID int64 `json:"id"` - TaskID int64 `json:"task_id"` - NodeID *int64 `json:"node_id,omitempty"` - Worker string `json:"worker"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Inherited bool `json:"inherited"` + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + NodeID *int64 `json:"node_id,omitempty"` + Worker string `json:"worker"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Inherited bool `json:"inherited"` + SourceSession string `json:"source_session,omitempty"` } type IntelligenceFilter struct { TaskID int64 @@ -133,10 +134,22 @@ func intelligenceFingerprint(in IntelligenceInput) string { const intelligenceCols = `id,task_id,node_id,kind,title,content,evidence,confidence,status,asset_ids,worker,created_at,updated_at` -func scanIntelligence(row interface{ Scan(...any) error }) (*Intelligence, error) { +// Resolve provenance from the graph, never from a worker's display number. +// A fact with ambiguous/missing parents deliberately has no exact session link. +const intelligenceSourceSession = `, COALESCE( + (SELECT 'intent:' || n.id::text FROM exploration_nodes n WHERE n.id=i.node_id AND n.exploration_id=t.exploration_id AND n.kind='intent'), + (SELECT 'intent:' || min(n.id)::text FROM exploration_edges e JOIN exploration_nodes n ON n.id=e.src_id AND n.kind='intent' + WHERE e.dst_id=i.node_id AND e.rel='yields' AND e.exploration_id=t.exploration_id AND n.exploration_id=t.exploration_id HAVING count(*)=1), + CASE WHEN i.worker='planner' THEN 'plan' ELSE '' END)` + +func scanIntelligence(row interface{ Scan(...any) error }, withSource ...bool) (*Intelligence, error) { item := &Intelligence{} var assets []byte - err := row.Scan(&item.ID, &item.TaskID, &item.NodeID, &item.Kind, &item.Title, &item.Content, &item.Evidence, &item.Confidence, &item.Status, &assets, &item.Worker, &item.CreatedAt, &item.UpdatedAt) + dest := []any{&item.ID, &item.TaskID, &item.NodeID, &item.Kind, &item.Title, &item.Content, &item.Evidence, &item.Confidence, &item.Status, &assets, &item.Worker, &item.CreatedAt, &item.UpdatedAt} + if len(withSource) > 0 && withSource[0] { + dest = append(dest, &item.SourceSession) + } + err := row.Scan(dest...) if err != nil { return nil, err } @@ -245,13 +258,13 @@ func (d *DB) ListIntelligence(ctx context.Context, f IntelligenceFilter) (*Intel } args = append(args, f.Limit, f.Offset) cols := `i.id,i.task_id,i.node_id,i.kind,i.title,i.content,i.evidence,i.confidence,i.status,i.asset_ids,i.worker,i.created_at,i.updated_at` - rows, err = d.QueryContext(ctx, `SELECT `+cols+where+` ORDER BY i.id DESC LIMIT $6 OFFSET $7`, args...) + rows, err = d.QueryContext(ctx, `SELECT `+cols+intelligenceSourceSession+where+` ORDER BY i.id DESC LIMIT $6 OFFSET $7`, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { - item, e := scanIntelligence(rows) + item, e := scanIntelligence(rows, true) if e != nil { return nil, e } @@ -261,7 +274,7 @@ func (d *DB) ListIntelligence(ctx context.Context, f IntelligenceFilter) (*Intel return page, rows.Err() } func (d *DB) GetIntelligence(ctx context.Context, id int64) (*Intelligence, error) { - item, err := scanIntelligence(d.QueryRowContext(ctx, `SELECT `+intelligenceCols+` FROM task_intelligence WHERE id=$1 AND EXISTS(SELECT 1 FROM tasks WHERE tasks.id=task_intelligence.task_id AND deleted_at IS NULL)`, id)) + item, err := scanIntelligence(d.QueryRowContext(ctx, `SELECT i.id,i.task_id,i.node_id,i.kind,i.title,i.content,i.evidence,i.confidence,i.status,i.asset_ids,i.worker,i.created_at,i.updated_at`+intelligenceSourceSession+` FROM task_intelligence i JOIN tasks t ON t.id=i.task_id AND t.deleted_at IS NULL WHERE i.id=$1`, id), true) if errors.Is(err, sql.ErrNoRows) { return nil, ErrIntelligenceNotFound } diff --git a/db/intelligence_test.go b/db/intelligence_test.go index 9a1ef5e..298fc8b 100644 --- a/db/intelligence_test.go +++ b/db/intelligence_test.go @@ -3,6 +3,7 @@ package db import ( "encoding/json" "errors" + "fmt" "strings" "testing" ) @@ -141,3 +142,87 @@ func TestIntelligenceSensitiveDataListIncludesContent(t *testing.T) { t.Fatalf("missing sensitive data content: %+v %v", page, err) } } + +func TestIntelligenceSourceSessionUsesGraphProvenance(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + task, err := d.CreateTask("source navigation", "local fixture", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(task.ID) + store := d.Exploration(task.ExplorationID) + intent, err := store.AddIntent(map[string]any{"summary": "origin worker"}, 5, nil, "qa") + if err != nil { + t.Fatal(err) + } + fact, err := store.AddNode("fact", map[string]any{"summary": "reported observation"}, 0, "confirmed", "qa", nil) + if err != nil { + t.Fatal(err) + } + if err = store.Link(intent, RelYields, fact); err != nil { + t.Fatal(err) + } + for _, node := range []int64{intent, fact} { + in := testIntelligenceInput() + in.Content = fmt.Sprintf("fixture node %d", node) + item, err := d.RecordIntelligence(t.Context(), task.ID, node, "work#3", in) + if err != nil { + t.Fatal(err) + } + got, err := d.GetIntelligence(t.Context(), item.ID) + if err != nil || got.SourceSession != fmt.Sprintf("intent:%d", intent) { + t.Fatalf("wrong source: %+v %v", got, err) + } + } + page, err := d.ListIntelligence(t.Context(), IntelligenceFilter{TaskID: task.ID}) + if err != nil || len(page.Items) != 2 { + t.Fatalf("list: %+v %v", page, err) + } + for _, item := range page.Items { + if item.SourceSession != fmt.Sprintf("intent:%d", intent) { + t.Fatalf("list source: %+v", item) + } + } + // Multiple yielding parents must not send the user to an arbitrary worker. + other, err := store.AddIntent(map[string]any{"summary": "other worker"}, 5, nil, "qa") + if err != nil { + t.Fatal(err) + } + if err = store.Link(other, RelYields, fact); err != nil { + t.Fatal(err) + } + page, err = d.ListIntelligence(t.Context(), IntelligenceFilter{TaskID: task.ID}) + if err != nil { + t.Fatal(err) + } + for _, item := range page.Items { + if *item.NodeID == fact && item.SourceSession != "" { + t.Fatalf("ambiguous fact has exact session: %+v", item) + } + } + // No guessed session from a display label when graph provenance is absent. + in := testIntelligenceInput() + in.Content = "orphan fixture" + item, err := d.RecordIntelligence(t.Context(), task.ID, 0, "work#3", in) + if err != nil { + t.Fatal(err) + } + got, err := d.GetIntelligence(t.Context(), item.ID) + if err != nil || got.SourceSession != "" { + t.Fatalf("guessed session: %+v %v", got, err) + } + // Asset IDs resolve across categories. + assetID, err := d.Assets().UpsertHTTPService(UpsertHTTPServiceReq{URL: fmt.Sprintf("https://navigation-%d.example.invalid", task.ID), TaskID: task.ID}) + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM assets WHERE id=$1`, assetID) + assets, err := d.Assets().QueryDSL(fmt.Sprintf("id==%d", assetID), "", 0, 1, 0) + if err != nil || len(assets) != 1 || assets[0].ID != assetID { + t.Fatalf("asset ID lookup failed: %+v %v", assets, err) + } +} diff --git a/web/src/app/(main)/function/assets/page.tsx b/web/src/app/(main)/function/assets/page.tsx index cea0c82..9073c95 100644 --- a/web/src/app/(main)/function/assets/page.tsx +++ b/web/src/app/(main)/function/assets/page.tsx @@ -2,6 +2,8 @@ import * as React from "react"; +import { useSearchParams } from "next/navigation"; + import { BuildingIcon, ChevronLeftIcon, @@ -108,7 +110,37 @@ const TABS: { key: string; label: string; icon: LucideIcon }[] = [ { key: "endpoint", label: "接口", icon: LinkIcon }, ]; -export default function AssetsPage() { +function AssetsContent() { + const searchParams = useSearchParams(); + const requestedAsset = searchParams.get("asset") ?? ""; + const [assetTarget, setAssetTarget] = React.useState<{ id: number; type: string }>(); + const [assetTargetError, setAssetTargetError] = React.useState(""); + React.useEffect(() => { + let alive = true; + setAssetTarget(undefined); + setAssetTargetError(""); + if (!/^[1-9]\d*$/.test(requestedAsset)) return; + api + .searchAssets(`id==${requestedAsset}`, "", 1) + .then((r) => { + if (!alive) return; + const asset = r.assets[0]; + if (!asset) { + setAssetTargetError(`资产 #${requestedAsset} 不存在或已删除`); + return; + } + setAssetTarget({ id: asset.id, type: asset.type }); + setTab(asset.type); + setPage(0); + setIntelligenceAsset(null); + }) + .catch((e) => { + if (alive) setAssetTargetError((e as Error).message); + }); + return () => { + alive = false; + }; + }, [requestedAsset]); const [intelligenceAsset, setIntelligenceAsset] = React.useState(null); const [rows, setRows] = React.useState([]); const [total, setTotal] = React.useState(0); @@ -232,14 +264,14 @@ export default function AssetsPage() { // Reset query + page + selection when switching tabs // biome-ignore lint/correctness/useExhaustiveDependencies: tab changes intentionally reset tab-local controls. React.useEffect(() => { - setQuery(""); + setQuery(assetTarget?.type === tab ? `id==${assetTarget.id}` : ""); setDslError(""); setSelected(new Set()); setRows([]); setRowsTab(""); setTotal(0); setLoaded(false); - }, [tab]); + }, [tab, assetTarget]); // biome-ignore lint/correctness/useExhaustiveDependencies: these controls intentionally reset server pagination. React.useEffect(() => setPage(0), [tab, size, query]); @@ -316,6 +348,11 @@ export default function AssetsPage() { return (
+ {assetTargetError ? ( +

+ {assetTargetError} +

+ ) : null} { @@ -328,7 +365,12 @@ export default function AssetsPage() { 按来源任务查看凭据、线索和敏感数据。 {intelligenceAsset !== null ? ( - + setIntelligenceAsset(null)} + /> ) : null} @@ -1243,3 +1285,11 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () ); } + +export default function AssetsPage() { + return ( + + + + ); +} 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 65da0c3..ca1bb11 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 @@ -478,9 +478,32 @@ function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) { ); } -export function SessionsTab({ taskId }: { taskId: string }) { +export function SessionsTab({ taskId, requestedSession = "" }: { taskId: string; requestedSession?: string }) { const approvalFocus = useApprovalFocus({ taskId }); const [selectedSessionId, setActiveId] = React.useState(MAIN_ID); + const [linkedSession, setLinkedSession] = React.useState(); + const hasRequestedSession = React.useRef(false); + hasRequestedSession.current = /^(intent:[1-9]\d*|main:\d+|plan)$/.test(requestedSession); + React.useEffect(() => { + let session: Session | undefined; + if (/^intent:[1-9]\d*$/.test(requestedSession)) { + const id = requestedSession.slice(7); + session = { + id, + intent_id: id, + role: "worker", + title: `Worker #${id}`, + status: "done", + live: false, + last_activity: "", + }; + } else if (/^main:\d+$/.test(requestedSession)) { + const seg = Number(requestedSession.slice(5)); + session = { ...MAIN_SESSION, id: mainSessionId(seg), seg, title: mainSessionTitle(seg) }; + } else if (requestedSession === "plan") session = PLANNER_SESSION; + setLinkedSession(session); + if (session) setActiveId(session.id); + }, [requestedSession]); const focusSession = React.useMemo(() => { const source = approvalFocus.state?.source; if (!source) return undefined; @@ -887,7 +910,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { setSessionTokens({}); setLLMResolutions(null); setSseLive(false); - setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in + if (!hasRequestedSession.current) setActiveId(MAIN_ID); // preserve an explicit session deep link setMainSegs([{ seq: 0, created_at: "" }]); setCurrentSeg(0); snapshotRef.current = 0; @@ -949,7 +972,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { bootKey = mainSessionKey(ms.current); if (bootKey !== mainSessionKey(0)) loadingKeysRef.current.delete(mainSessionKey(0)); loadingKeysRef.current.add(bootKey); - setActiveId(mainSessionId(ms.current)); + if (!hasRequestedSession.current) setActiveId(mainSessionId(ms.current)); patchStore(bootKey, (s) => ({ ...s, loading: true })); return api.activityHistory(taskId, bootKey, 0, PAGE); }) @@ -969,7 +992,9 @@ export function SessionsTab({ taskId }: { taskId: string }) { }; }); // Open the single task SSE from the snapshot cursor. - const es = openSSE(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`); + const es = openSSE( + `/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`, + ); esRef.current = es; es.onopen = () => setSseLive(true); es.onerror = () => setSseLive(false); // EventSource auto-reconnects; DB compensates the gap @@ -1244,10 +1269,10 @@ export function SessionsTab({ taskId }: { taskId: string }) { const sessions = React.useMemo(() => { const items = [...mainSessions, { ...PLANNER_SESSION, live: plannerLive }, ...workerSessions, SYSTEM_SESSION]; - const located = focusSession ?? retainedSession; + const located = focusSession ?? retainedSession ?? linkedSession; if (located && !items.some((s) => s.id === located.id)) items.push(located); return items; - }, [mainSessions, workerSessions, plannerLive, focusSession, retainedSession]); + }, [mainSessions, workerSessions, plannerLive, focusSession, retainedSession, linkedSession]); const grouped = { mainagent: sessions.filter((s) => s.role === "mainagent"), diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index d29392c..3c23d3e 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -424,7 +424,7 @@ function TaskDetailInner() { {/* Tab content */}
- + diff --git a/web/src/components/intelligence-panel.tsx b/web/src/components/intelligence-panel.tsx index 6a85f4a..c243122 100644 --- a/web/src/components/intelligence-panel.tsx +++ b/web/src/components/intelligence-panel.tsx @@ -44,7 +44,35 @@ function time(value: string) { return new Date(value).toLocaleString("zh-CN"); } -export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets"; scopeId: string }) { +function AssetLinks({ ids, onNavigate }: { ids: number[]; onNavigate?: () => void }) { + return ids.length ? ( + + {ids.map((id) => ( + + #{id} + + ))} + + ) : ( + <>未关联 + ); +} + +export function IntelligencePanel({ + scope, + scopeId, + onNavigate, +}: { + scope: "tasks" | "assets"; + scopeId: string; + onNavigate?: () => void; +}) { const [kind, setKind] = React.useState("all"); const [status, setStatus] = React.useState("all"); const [search, setSearch] = React.useState(""); @@ -96,6 +124,10 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets }, [scope, scopeId, kind, status, query, page, refresh]); const open = (item: TaskIntelligence) => setSelected(item); const close = () => setSelected(null); + const navigate = () => { + close(); + onNavigate?.(); + }; const update = async (value: string) => { if (!selected) return; setSaving(true); @@ -251,7 +283,11 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
- + 任务 #{item.task_id} @@ -262,7 +298,7 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
- {item.asset_ids.length ? item.asset_ids.map((id) => `#${id}`).join("、") : "未关联"} + {time(item.created_at)} @@ -373,11 +409,18 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets {selected.evidence}
- 关联资产:{selected.asset_ids.map((id) => `#${id}`).join("、") || "未关联"} + + 关联资产: + + 发现于 {time(selected.created_at)}
- - 查看来源任务会话 + + {selected.source_session ? "查看来源任务会话" : "查看来源任务"}
) : null} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 2d4ae6c..d9d4ca4 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1745,6 +1745,7 @@ export interface FindingCaseReviewRun { export type IntelligenceKind = "credential" | "lead" | "sensitive_data"; export interface TaskIntelligence { + source_session?: string; id: number; task_id: number; node_id?: number;