- ExploreKind/ExploreRel 增加 digest/covers 类型 - digest 节点独立样式(青色 Layers)、图例与摘要(body) - collapseDigestGraph:active digest 覆盖的 fact/intent 成员从图中隐藏, 触及成员的边端点改接到 digest,成员内部边丢弃并去重,covers 边不渲染 - 详情抽屉展示压缩结论与被压缩成员列表,成员可点击下钻
652 lines
26 KiB
TypeScript
652 lines
26 KiB
TypeScript
"use client";
|
||
"use no memo";
|
||
|
||
import * as React from "react";
|
||
import {
|
||
Background,
|
||
BackgroundVariant,
|
||
Controls,
|
||
type Edge as RFEdge,
|
||
Handle,
|
||
MarkerType,
|
||
MiniMap,
|
||
type Node as RFNode,
|
||
type NodeProps,
|
||
Panel,
|
||
Position,
|
||
ReactFlow,
|
||
ReactFlowProvider,
|
||
useEdgesState,
|
||
useNodesState,
|
||
} from "@xyflow/react";
|
||
import "@xyflow/react/dist/style.css";
|
||
import {
|
||
Bug,
|
||
Compass,
|
||
Flag,
|
||
FlaskConical,
|
||
Layers,
|
||
Lightbulb,
|
||
type LucideIcon,
|
||
Target,
|
||
} from "lucide-react";
|
||
|
||
import { cn } from "@/lib/utils";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { StatusBadge } from "@/components/status-badge";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/components/ui/sheet";
|
||
import { toneClasses, type Tone } from "@/lib/status";
|
||
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
|
||
|
||
export const relLabel: Record<string, string> = {
|
||
spawns: "派生",
|
||
derived_from: "意图链",
|
||
yields: "产出",
|
||
proves: "证明",
|
||
};
|
||
|
||
export const relColor: Record<string, string> = {
|
||
spawns: "#10b981",
|
||
derived_from: "#6366f1",
|
||
yields: "#f59e0b",
|
||
proves: "#ef4444",
|
||
};
|
||
|
||
type TypeMeta = {
|
||
label: string;
|
||
icon: LucideIcon;
|
||
iconBg: string;
|
||
chip: string;
|
||
hex: string;
|
||
};
|
||
|
||
const typeMeta: Record<ExploreKind, TypeMeta> = {
|
||
task: { label: "根任务", icon: Flag, iconBg: "bg-slate-500", chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", hex: "#64748b" },
|
||
begin: { label: "起点", icon: Flag, iconBg: "bg-slate-500", chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", hex: "#64748b" },
|
||
goal: { label: "目标", icon: Target, iconBg: "bg-emerald-500", chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", hex: "#10b981" },
|
||
hint: { label: "提示", icon: Lightbulb, iconBg: "bg-violet-500", chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400", hex: "#8b5cf6" },
|
||
intent: { label: "意图", icon: Compass, iconBg: "bg-blue-500", chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400", hex: "#3b82f6" },
|
||
fact: { label: "事实", icon: FlaskConical, iconBg: "bg-amber-500", chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400", hex: "#f59e0b" },
|
||
finding: { label: "漏洞", icon: Bug, iconBg: "bg-rose-500", chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400", hex: "#f43f5e" },
|
||
digest: { label: "压缩", icon: Layers, iconBg: "bg-teal-500", chip: "bg-teal-500/15 text-teal-600 dark:text-teal-400", hex: "#14b8a6" },
|
||
};
|
||
|
||
// The task root is an origin fact (kind='fact', state='origin'); render it as "起点".
|
||
function viewKind(n: TaskNode): ExploreKind {
|
||
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
|
||
}
|
||
|
||
function prioTone(p: number): Tone {
|
||
return p >= 8 ? "red" : p >= 5 ? "amber" : "slate";
|
||
}
|
||
|
||
const summaryFields: Record<ExploreKind, string[]> = {
|
||
task: ["summary", "description"],
|
||
begin: ["summary", "description"],
|
||
goal: ["text"],
|
||
hint: [],
|
||
intent: ["summary"],
|
||
fact: ["summary"],
|
||
finding: ["name", "summary"],
|
||
digest: ["body"],
|
||
};
|
||
|
||
function nodeSummary(n: TaskNode): string {
|
||
const raw = n.payload ?? "";
|
||
const fields = summaryFields[viewKind(n)] ?? [];
|
||
if (fields.length && raw.trim()) {
|
||
try {
|
||
const obj: unknown = JSON.parse(raw);
|
||
if (obj && typeof obj === "object") {
|
||
for (const f of fields) {
|
||
const v = (obj as Record<string, unknown>)[f];
|
||
if (typeof v === "string" && v.trim()) return v;
|
||
}
|
||
}
|
||
} catch {
|
||
/* 非 JSON:原样显示 */
|
||
}
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
// digestMemberIds reads the fact/intent ids a digest folds from its payload
|
||
// (member_ids.{facts,intents}); ids are numbers in JSON, node ids are strings.
|
||
function digestMemberIds(n: TaskNode): string[] {
|
||
if (n.type !== "digest" || !n.payload) return [];
|
||
try {
|
||
const p = JSON.parse(n.payload) as {
|
||
member_ids?: { facts?: Array<string | number>; intents?: Array<string | number> };
|
||
};
|
||
return [...(p.member_ids?.facts ?? []), ...(p.member_ids?.intents ?? [])].map(String);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// collapseDigestGraph contracts each active digest's covered members into the
|
||
// digest super-node: the members are dropped from the rendered set and every
|
||
// edge touching a member is rewired onto its digest (member→member internal
|
||
// edges collapse to a self-loop and are dropped; the resulting edges are
|
||
// deduped by src+rel+dst). covers edges are structural and never drawn.
|
||
// Superseded digests (no covers edges) are dropped entirely. A graph with no
|
||
// active digest passes through unchanged. The full node list is kept by the
|
||
// caller so the detail drawer can still resolve a folded member by id.
|
||
function collapseDigestGraph(nodes: TaskNode[], edges: Edge[]): { viewNodes: TaskNode[]; viewEdges: Edge[] } {
|
||
const activeDigests = new Set(
|
||
nodes.filter((n) => n.type === "digest" && n.state === "active").map((n) => n.id),
|
||
);
|
||
const memberToDigest = new Map<string, string>();
|
||
for (const e of edges) {
|
||
if (e.rel === "covers" && activeDigests.has(e.src)) memberToDigest.set(e.dst, e.src);
|
||
}
|
||
const viewNodes = nodes.filter((n) =>
|
||
n.type === "digest" ? n.state === "active" : !memberToDigest.has(n.id),
|
||
);
|
||
const remap = (id: string) => memberToDigest.get(id) ?? id;
|
||
const seen = new Set<string>();
|
||
const viewEdges: Edge[] = [];
|
||
for (const e of edges) {
|
||
if (e.rel === "covers") continue;
|
||
const src = remap(e.src);
|
||
const dst = remap(e.dst);
|
||
if (src === dst) continue;
|
||
const key = `${src} |