fix: navigate intelligence to source sessions and related assets

This commit is contained in:
ji ruo committed 2026-10-20 12:13:07 +08:00
1 parent fb8c3cb6f4
commit a00e58a9bd
8 files changed
+247 -29

No files matched your search

+1
View File
@@ -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",
+25 -12
View File
@@ -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
}
+85
View File
@@ -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)
}
}
+54 -4
View File
@@ -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<number | null>(null);
const [rows, setRows] = React.useState<Asset[]>([]);
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 (
<div className="flex h-[calc(100vh-6rem)] min-h-0 flex-col gap-4">
{assetTargetError ? (
<p role="alert" className="text-sm text-destructive">
{assetTargetError}
</p>
) : null}
<Dialog
open={intelligenceAsset !== null}
onOpenChange={(open) => {
@@ -328,7 +365,12 @@ export default function AssetsPage() {
<DialogDescription>按来源任务查看凭据、线索和敏感数据。</DialogDescription>
</DialogHeader>
{intelligenceAsset !== null ? (
<IntelligencePanel key={intelligenceAsset} scope="assets" scopeId={String(intelligenceAsset)} />
<IntelligencePanel
key={intelligenceAsset}
scope="assets"
scopeId={String(intelligenceAsset)}
onNavigate={() => setIntelligenceAsset(null)}
/>
) : null}
</DialogContent>
</Dialog>
@@ -1243,3 +1285,11 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
</Dialog>
);
}
export default function AssetsPage() {
return (
<React.Suspense fallback={null}>
<AssetsContent />
</React.Suspense>
);
}
@@ -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<Session>();
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<Session | undefined>(() => {
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"),
@@ -424,7 +424,7 @@ function TaskDetailInner() {
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
<TabsContent value="sessions" className="mt-0">
<SessionsTab taskId={id} />
<SessionsTab key={id} taskId={id} requestedSession={searchParams.get("session") ?? ""} />
</TabsContent>
<TabsContent value="overview" className="mt-0">
<OverviewTab taskId={id} />
+49 -6
View File
@@ -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 ? (
<span className="inline-flex flex-wrap gap-x-2 gap-y-1">
{ids.map((id) => (
<Link
key={id}
href={`/function/assets?asset=${id}`}
onClick={onNavigate}
className="underline underline-offset-2"
aria-label={`查看资产 #${id}`}
>
#{id}
</Link>
))}
</span>
) : (
<>未关联</>
);
}
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
</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
<div className="flex flex-col gap-1">
<Link href={`/function/tasks/detail?id=${item.task_id}&tab=intelligence`} className="underline">
<Link
href={`/function/tasks/detail?id=${item.task_id}&tab=intelligence`}
onClick={navigate}
className="underline"
>
任务 #{item.task_id}
</Link>
<span className="text-xs text-muted-foreground">
@@ -262,7 +298,7 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
</div>
</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
{item.asset_ids.length ? item.asset_ids.map((id) => `#${id}`).join("、") : "未关联"}
<AssetLinks ids={item.asset_ids} onNavigate={navigate} />
</TableCell>
<TableCell className="align-top whitespace-normal text-xs">{time(item.created_at)}</TableCell>
<TableCell className="align-top whitespace-normal [overflow-wrap:anywhere]">
@@ -373,11 +409,18 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
{selected.evidence}
</pre>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>关联资产:{selected.asset_ids.map((id) => `#${id}`).join("、") || "未关联"}</span>
<span>
关联资产:
<AssetLinks ids={selected.asset_ids} onNavigate={navigate} />
</span>
<span>发现于 {time(selected.created_at)}</span>
</div>
<Link href={`/function/tasks/detail?id=${selected.task_id}&tab=sessions`} className="text-sm underline">
查看来源任务会话
<Link
href={`/function/tasks/detail?id=${selected.task_id}&tab=sessions${selected.source_session ? `&session=${encodeURIComponent(selected.source_session)}` : ""}`}
onClick={navigate}
className="text-sm underline"
>
{selected.source_session ? "查看来源任务会话" : "查看来源任务"}
</Link>
</div>
) : null}
+1
View File
@@ -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;