Add under-anything knowledge dashboard

This commit is contained in:
qiaoxinjiu
2026-05-27 15:40:32 +08:00
commit e31a75d2bb
565 changed files with 143063 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Understand Anything</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
{
"name": "@understand-anything/dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:demo": "tsc -b && vite build --config vite.config.demo.ts",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@understand-anything/core": "workspace:*",
"@xyflow/react": "^12.0.0",
"d3-force": "^3.0.0",
"devlop": "^1.1.0",
"elkjs": "^0.9.3",
"graphology": "^0.25.4",
"graphology-communities-louvain": "^2.0.1",
"graphology-types": "^0.24.8",
"hast-util-to-jsx-runtime": "^2.3.6",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/d3-force": "^3.0.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.4.2",
"vitest": "^3.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0a0a0a"/>
<text x="16" y="23" font-family="Georgia, serif" font-size="20" fill="#d4a574" text-anchor="middle" font-weight="bold">U</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"lastAnalyzedAt": "2026-05-26T10:06:42.129970+00:00",
"gitCommitHash": "",
"version": "1.0.0",
"analyzedFiles": 42,
"theme": {
"presetId": "dark",
"accentId": "cyan"
}
}

View File

@@ -0,0 +1,99 @@
// Per-layer aggregation perf benchmark.
//
// Mirrors the BEFORE shape (graph.nodes.filter(n => layer.nodeIds.includes(n.id))
// per layer) and the AFTER shape (single nodesById Map + iterate layer.nodeIds)
// from `useOverviewGraph` in `src/components/GraphView.tsx`. Issue #102 reported
// a 4.8 MB knowledge graph that froze the dashboard on overview render — the
// quadratic Array.includes pass was the dominant synchronous cost.
//
// We can't import the dashboard helper directly (Vite-bundled, no
// per-module dist), so the new shape is reproduced here in lockstep with
// `src/utils/layerStats.ts::computeLayerStats`.
//
// Usage:
// node understand-anything-plugin/packages/dashboard/scripts/benchmark-aggregations.mjs
import { performance } from "node:perf_hooks";
function makeGraph(layerCount, nodesPerLayer) {
const nodes = [];
const layers = [];
for (let li = 0; li < layerCount; li++) {
const ids = [];
for (let ni = 0; ni < nodesPerLayer; ni++) {
const id = `n-${li}-${ni}`;
const complexity = ["simple", "moderate", "complex"][(li + ni) % 3];
nodes.push({ id, complexity });
ids.push(id);
}
layers.push({ id: `L${li}`, nodeIds: ids });
}
return { nodes, layers };
}
// --- BEFORE: O(N × K × L) per overview render ----------------------------
function aggregateBefore(graph) {
const out = [];
for (const layer of graph.layers) {
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
const c = { simple: 0, moderate: 0, complex: 0 };
for (const n of memberNodes) c[n.complexity]++;
const aggregate =
c.complex > memberNodes.length * 0.3
? "complex"
: c.moderate > memberNodes.length * 0.3
? "moderate"
: "simple";
out.push({ id: layer.id, aggregateComplexity: aggregate });
}
return out;
}
// --- AFTER: O(N + Σ K_i) per overview render ----------------------------
function aggregateAfter(graph, nodesById) {
const out = [];
for (const layer of graph.layers) {
const c = { simple: 0, moderate: 0, complex: 0 };
let resolved = 0;
for (const nid of layer.nodeIds) {
const node = nodesById.get(nid);
if (!node) continue;
resolved++;
c[node.complexity]++;
}
const aggregate =
c.complex > resolved * 0.3
? "complex"
: c.moderate > resolved * 0.3
? "moderate"
: "simple";
out.push({ id: layer.id, aggregateComplexity: aggregate });
}
return out;
}
function bench(label, layerCount, nodesPerLayer) {
const graph = makeGraph(layerCount, nodesPerLayer);
const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
const t0 = performance.now();
const before = aggregateBefore(graph);
const t1 = performance.now();
const after = aggregateAfter(graph, nodesById);
const t2 = performance.now();
const beforeMs = t1 - t0;
const afterMs = t2 - t1;
const speedup = afterMs > 0 ? beforeMs / afterMs : Infinity;
const parity = JSON.stringify(before) === JSON.stringify(after);
console.log(
`${label} (${layerCount} layers × ${nodesPerLayer} nodes = ${graph.nodes.length} total): ` +
`BEFORE ${beforeMs.toFixed(1)}ms | AFTER ${afterMs.toFixed(1)}ms | ` +
`${speedup.toFixed(1)}× faster | parity ${parity}`,
);
}
bench("small", 10, 50);
bench("medium", 30, 100);
bench("large", 50, 200);
bench("issue#102 shape", 100, 200);

View File

@@ -0,0 +1,80 @@
// Stage 1 ELK layout perf benchmark.
//
// Mirrors `applyElkLayout` from `src/utils/elk-layout.ts` using `elkjs`
// directly. The dashboard build is a Vite bundle (hashed chunks), so it has
// no per-module `dist/utils/elk-layout.js` we can import. The Stage 1 hot
// path is `elk.layout()` on a sized input, which we reproduce faithfully
// here — same default node dimensions, same dim-defaulting behavior.
//
// Targets (spec §8.3):
// - Stage 1 < 200ms at 500 nodes
// - Stage 1 < 500ms at 3000 nodes
//
// Usage:
// node understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs
import { performance } from "node:perf_hooks";
import ELK from "elkjs/lib/elk.bundled.js";
// Keep in lockstep with NODE_WIDTH / NODE_HEIGHT in src/utils/layout.ts.
const DEFAULT_NODE_WIDTH = 280;
const DEFAULT_NODE_HEIGHT = 120;
const elk = new ELK();
/**
* Default missing width/height on every node (mirrors repairElkInput's
* ensureNodeDimensions step). Stage 1 in prod always feeds ELK sized nodes,
* but the repair pass is part of the measured path so we model it.
*/
function fillDims(children) {
return children.map((c) => {
const next = { ...c };
if (next.width == null) next.width = DEFAULT_NODE_WIDTH;
if (next.height == null) next.height = DEFAULT_NODE_HEIGHT;
if (next.children) next.children = fillDims(next.children);
return next;
});
}
async function applyElkLayout(input) {
const repaired = { ...input, children: fillDims(input.children) };
return elk.layout(repaired);
}
/**
* Synthetic Stage 1 graph: top-level container nodes with a sparse edge mesh.
* Stage 1 only lays out containers (lazy children — see plan §3), so the
* "node count" parameter is interpreted as total leaves while the container
* count scales sub-linearly, matching production shape.
*/
function makeGraph(nodeCount, containerCount = Math.min(20, Math.ceil(nodeCount / 25))) {
const containers = Array.from({ length: containerCount }, (_, i) => ({
id: `c${i}`,
width: 400,
height: 300,
}));
const edges = [];
for (let i = 0; i < containerCount; i++) {
for (let j = i + 1; j < containerCount; j++) {
if (Math.random() < 0.3) {
edges.push({ id: `e-${i}-${j}`, sources: [`c${i}`], targets: [`c${j}`] });
}
}
}
return { id: "root", children: containers, edges };
}
async function bench(label, n) {
const input = makeGraph(n);
const t0 = performance.now();
await applyElkLayout(input);
const t1 = performance.now();
const ms = t1 - t0;
console.log(`${label} (${n} nodes): ${ms.toFixed(1)}ms`);
return ms;
}
await bench("Stage1", 500);
await bench("Stage1", 1000);
await bench("Stage1", 3000);

View File

@@ -0,0 +1,267 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(r"D:\AIcoding\WishFulfilled\知识库\under-anything\wishfulfilled-wiki")
DASHBOARD_PUBLIC = Path(r"D:\AIcoding\WishFulfilled\知识库\under-anything\Understand-Anything-main\understand-anything-plugin\packages\dashboard\public")
UA_DIR = ROOT / ".understand-anything"
SECTIONS = [
{
"id": "layer-overview",
"name": "知识库入口",
"description": "知识库使用说明、首页、知识地图和问答入口。先从这里理解知识库结构与检索方式。",
"paths": ["知识库使用说明.md", "欢迎.md", "00_首页"],
},
{
"id": "layer-requirements",
"name": "需求文档",
"description": "所有正式需求、业务规则、需求变更和需求索引。点击本层可查看全部需求文档并检索。",
"paths": ["05_需求文档"],
},
{
"id": "layer-milestones",
"name": "里程碑",
"description": "项目阶段计划、里程碑节点、评审记录、准入准出和交付物节奏。",
"paths": ["06_里程碑", "02_项目管理流程"],
},
{
"id": "layer-technical",
"name": "技术文档",
"description": "系统架构、数据模型、接口说明、技术方案和技术决策。",
"paths": ["07_技术文档"],
},
{
"id": "layer-testing",
"name": "测试相关",
"description": "测试计划、测试用例、缺陷记录、验收记录和上线检查。",
"paths": ["08_测试相关"],
},
{
"id": "layer-agent",
"name": "Agent检索",
"description": "检索说明、关键词、同义词、来源索引和持续更新验证流程。",
"paths": ["04_Agent检索"],
},
]
EXCLUDE_DIRS = {".git", ".obsidian", ".understand-anything", "raw", "99_归档"}
def rel(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def iter_markdown() -> list[Path]:
files: list[Path] = []
for p in ROOT.rglob("*.md"):
parts = set(p.relative_to(ROOT).parts)
if parts & EXCLUDE_DIRS:
continue
files.append(p)
return sorted(files, key=lambda x: rel(x))
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="ignore")
def title_from_content(path: Path, content: str) -> str:
for line in content.splitlines():
line = line.strip()
if line.startswith("# "):
return line[2:].strip()
return path.stem
def summary_from_content(content: str) -> str:
in_frontmatter = False
started = False
for raw in content.splitlines():
line = raw.strip()
if line == "---" and not started:
in_frontmatter = True
started = True
continue
if line == "---" and in_frontmatter:
in_frontmatter = False
continue
if in_frontmatter or not line or line.startswith("#") or line.startswith("---"):
continue
if line.startswith("|") or line.startswith("```"):
continue
return line[:180]
return "知识库文档。"
def tags_for(path: Path) -> list[str]:
parts = path.relative_to(ROOT).parts
tags = [parts[0]] if parts else []
name = path.stem
if "需求" in name or "05_需求文档" in parts:
tags.append("需求文档")
if "测试" in name or "08_测试相关" in parts:
tags.append("测试相关")
if "技术" in name or "07_技术文档" in parts:
tags.append("技术文档")
if "里程碑" in name or "06_里程碑" in parts:
tags.append("里程碑")
if "Agent" in name or "04_Agent检索" in parts:
tags.append("Agent检索")
return list(dict.fromkeys(tags))
def layer_for(path: Path) -> str | None:
rp = rel(path)
for section in SECTIONS:
for prefix in section["paths"]:
prefix_norm = prefix.replace("\\", "/")
if rp == prefix_norm or rp.startswith(prefix_norm.rstrip("/") + "/"):
return section["id"]
return None
def edge(source: str, target: str, type_: str = "documents", weight: float = 0.8, description: str = "") -> dict:
allowed = {
"documents": "documents",
"related": "related",
"depends_on": "depends_on",
}
mapped = allowed.get(type_, "related")
return {
"source": source,
"target": target,
"type": mapped,
"direction": "forward",
"description": description or mapped,
"weight": weight,
}
def main() -> None:
nodes = []
layer_node_ids: dict[str, list[str]] = {s["id"]: [] for s in SECTIONS}
path_to_id: dict[str, str] = {}
for path in iter_markdown():
lid = layer_for(path)
if not lid:
continue
content = read_text(path)
rp = rel(path)
node_id = "doc:" + rp[:-3]
path_to_id[rp] = node_id
node = {
"id": node_id,
"type": "document",
"name": title_from_content(path, content),
"filePath": rp,
"summary": summary_from_content(content),
"tags": tags_for(path),
"complexity": "moderate" if len(content) > 4000 else "simple",
"knowledgeMeta": {
"content": content,
"wikilinks": [],
"category": lid,
},
}
nodes.append(node)
layer_node_ids[lid].append(node_id)
# Add one virtual process node per layer so the overview forms a clear flow even when a layer has many docs.
flow_nodes = []
for order, section in enumerate(SECTIONS, start=1):
node_id = f"flow:{section['id']}"
flow_nodes.append(node_id)
docs = layer_node_ids[section["id"]]
node = {
"id": node_id,
"type": "document",
"name": f"{order}. {section['name']}",
"summary": section["description"],
"tags": ["流程入口", section["name"]],
"complexity": "simple",
"knowledgeMeta": {
"content": f"# {section['name']}\n\n{section['description']}\n\n本层包含 {len(docs)} 个文档。点击右侧 Files 或在本层详情中选择具体文档查看内容。",
"wikilinks": [],
"category": section["id"],
},
}
nodes.append(node)
layer_node_ids[section["id"]].insert(0, node_id)
edges = []
for a, b in zip(flow_nodes, flow_nodes[1:]):
edges.append(edge(a, b, "documents", 1.0, "知识库主流程"))
for section in SECTIONS:
root_id = f"flow:{section['id']}"
for doc_id in layer_node_ids[section["id"]][1:]:
edges.append(edge(root_id, doc_id, "documents", 0.65, "本层文档"))
# Important requirement docs should build on their upstream links when those linked files exist in this knowledge base.
for node in nodes:
if not node.get("filePath"):
continue
content = node.get("knowledgeMeta", {}).get("content", "")
for rp, target_id in path_to_id.items():
if rp != node["filePath"] and Path(rp).name in content:
edges.append(edge(target_id, node["id"], "depends_on", 0.7, "文档引用关系"))
layers = [
{
"id": section["id"],
"name": section["name"],
"description": section["description"],
"nodeIds": layer_node_ids[section["id"]],
}
for section in SECTIONS
]
graph = {
"version": "1.0.0",
"kind": "codebase",
"project": {
"name": "如愿知识库",
"languages": ["markdown"],
"frameworks": ["Understand-Anything", "Obsidian"],
"description": "按需求文档、里程碑、技术文档、测试相关、Agent检索组织的流程式知识库。",
"analyzedAt": datetime.now(timezone.utc).isoformat(),
"gitCommitHash": "",
},
"nodes": nodes,
"edges": edges,
"layers": layers,
"tour": [
{
"order": i,
"title": section["name"],
"description": section["description"],
"nodeIds": [f"flow:{section['id']}"],
}
for i, section in enumerate(SECTIONS, start=1)
],
}
UA_DIR.mkdir(parents=True, exist_ok=True)
DASHBOARD_PUBLIC.mkdir(parents=True, exist_ok=True)
for target in [UA_DIR / "knowledge-graph.json", DASHBOARD_PUBLIC / "knowledge-graph.json"]:
target.write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
meta = {
"lastAnalyzedAt": graph["project"]["analyzedAt"],
"gitCommitHash": "",
"version": "1.0.0",
"analyzedFiles": len([n for n in nodes if n.get("filePath")]),
"theme": {"presetId": "dark", "accentId": "cyan"},
}
for target in [UA_DIR / "meta.json", DASHBOARD_PUBLIC / "meta.json"]:
target.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Generated {len(nodes)} nodes, {len(edges)} edges, {len(layers)} layers")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,563 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultStaticRoot = path.resolve(__dirname, "..", "..", "..", "..", "..", "wishfulfilled-dashboard");
const staticRoot = process.env.RAG_STATIC_ROOT || defaultStaticRoot;
const graphPath = process.env.RAG_GRAPH_PATH || path.join(staticRoot, "knowledge-graph.json");
const port = Number(process.env.PORT || process.env.RAG_PORT || 8080);
const host = process.env.HOST || "0.0.0.0";
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
};
const SYNONYMS = {
回评: ["评价提交", "评价展示", "客服邀评", "评价结果追踪", "review submission", "review display", "review follow-up", "邀评", "留评"],
黑名单: ["blacklist", "blacklist_entities", "风险", "风控", "风险案件", "risk_cases", "risk_signals", "反欺诈", "拦截"],
测评: ["评价计划", "review plan", "评价提交", "额度统计", "测评单"],
免评: ["KOC", "KOL", "内容发布", "引流", "带货", "exemption"],
真实人: ["person", "person_profiles", "跨账号归并", "身份归并", "真实人归并"],
额度: ["4/4/12", "person_quota_ledgers", "额度台账", "预占", "额度与频控"],
对外API: ["对外 API 契约", "接口契约", "API 草案", "接口说明", "对外接口"],
工单: ["客服工单", "support_tickets", "客服跟进", "support_followups"],
风险: ["风险与反欺诈", "risk_signals", "risk_cases", "黑名单", "反欺诈"],
};
let graphCache = null;
let graphMtime = 0;
let chunksCache = [];
function sendJson(res, status, data) {
const body = JSON.stringify(data, null, 2);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
});
res.end(body);
}
function normalizeText(value) {
if (Array.isArray(value)) return value.join(" ");
return typeof value === "string" ? value : "";
}
function stripHtml(text) {
return text
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function stripInjectedLineNumbers(text) {
const lines = text.split("\n");
let changed = 0;
const cleaned = lines.map((line) => {
const match = line.match(/^\s*\d+\|(.+)$/);
if (!match) return line;
changed += 1;
return match[1];
});
return changed >= Math.max(3, Math.floor(lines.length / 4)) ? cleaned.join("\n") : text;
}
function compactKey(value) {
return String(value || "")
.toLowerCase()
.replace(/[\s`*_>#|\-\[\]()()【】《》“”"',。、::;,.\/\\!?]+/g, "");
}
function normalizeForSearch(value) {
return stripHtml(stripInjectedLineNumbers(String(value || "")))
.toLowerCase()
.replace(/[\t\r]+/g, " ")
.replace(/[]/g, " ")
.replace(/[【】《》“”]/g, " ")
.replace(/[_*`>#|\[\]]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function unique(values) {
return Array.from(new Set(values.map((v) => String(v || "").trim()).filter(Boolean)));
}
function tokenizeText(text) {
const lower = normalizeForSearch(text);
const latin = lower.match(/[a-z0-9_.\/+-]{2,}/g) || [];
const cjkRuns = lower.match(/[\u4e00-\u9fa5]{2,}/g) || [];
const cjkChars = lower.match(/[\u4e00-\u9fa5]/g) || [];
const ngrams = [];
for (let i = 0; i < cjkChars.length - 1; i += 1) ngrams.push(`${cjkChars[i]}${cjkChars[i + 1]}`);
for (let i = 0; i < cjkChars.length - 2; i += 1) ngrams.push(`${cjkChars[i]}${cjkChars[i + 1]}${cjkChars[i + 2]}`);
return unique([...latin, ...cjkRuns, ...ngrams, ...cjkChars.filter((ch) => !/[是什么吗呢的了和与及或在中为]/.test(ch))]);
}
function detectQuestionType(query) {
const text = normalizeForSearch(query);
if (/api|接口|契约|endpoint|对外/.test(text)) return "api";
if (/数据|字段|表|对象|模型|schema|建表/.test(text)) return "data_model";
if (/流程|怎么走|如何|步骤|流转|处理/.test(text)) return "process";
if (/规则|限制|口径|额度|频控|条件/.test(text)) return "rule";
if (/谁|负责|职责|owner|角色/.test(text)) return "responsibility";
if (/测试|验收|用例|缺陷|上线/.test(text)) return "test";
if (/里程碑|阶段|计划|节点/.test(text)) return "milestone";
if (/是什么|定义|含义|意思|解释/.test(text)) return "definition";
return "general";
}
function expandTerms(tokens, raw) {
const compact = compactKey(raw);
const expanded = [];
for (const [term, values] of Object.entries(SYNONYMS)) {
const termCompact = compactKey(term);
if (compact.includes(termCompact) || tokens.some((token) => compactKey(token).includes(termCompact) || termCompact.includes(compactKey(token)))) {
expanded.push(term, ...values);
}
}
return unique(expanded);
}
function processQuery(raw) {
const normalized = normalizeForSearch(raw);
const compact = compactKey(raw);
const tokens = tokenizeText(raw);
const expandedTerms = expandTerms(tokens, raw);
const coreTerms = unique([String(raw || "").trim(), normalized, compact, ...tokens.filter((token) => token.length >= 2)]);
return { raw, normalized, compact, tokens, coreTerms, expandedTerms, questionType: detectQuestionType(raw) };
}
function loadGraph() {
const stat = fs.statSync(graphPath);
if (graphCache && stat.mtimeMs === graphMtime) return graphCache;
const graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
graphCache = graph;
graphMtime = stat.mtimeMs;
chunksCache = buildChunks(graph.nodes || []);
return graph;
}
function nodeContent(node) {
return stripHtml(stripInjectedLineNumbers(normalizeText(node?.knowledgeMeta?.content)));
}
function nodeLayer(node) {
return normalizeText(node?.knowledgeMeta?.category) || (node?.tags || []).find((tag) => /需求|技术|测试|里程碑/.test(tag)) || "";
}
function detectChunkType(heading, content) {
const text = `${heading}\n${content}`.toLowerCase();
if (/api|接口|契约|endpoint|对外/.test(text)) return "api_contract";
if (/数据模型|字段|表结构|对象|schema|建表/.test(text)) return "data_model";
if (/业务规则|规则|口径|额度|频控|限制/.test(text)) return "business_rule";
if (/流程|流转|步骤|时序|处理/.test(text)) return "process";
if (/faq|常见问题|是什么|问答/.test(text)) return "faq";
return "general";
}
function splitSections(text) {
const source = text.trim();
if (!source) return [];
const sections = [];
let currentHeading = "全文";
let buffer = [];
for (const line of source.split("\n")) {
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+)$/)?.[1]?.trim();
if (heading) {
if (buffer.length > 0) sections.push({ heading: currentHeading, text: buffer.join("\n") });
currentHeading = heading;
buffer = [line];
} else {
buffer.push(line);
}
}
if (buffer.length > 0) sections.push({ heading: currentHeading, text: buffer.join("\n") });
return sections.length ? sections : [{ heading: "全文", text: source }];
}
function buildChunks(nodes) {
return nodes.flatMap((node) => {
const content = nodeContent(node);
const titleBlock = [node.name, node.summary, node.filePath, (node.tags || []).join(" ")].join("\n");
const sections = splitSections(content || titleBlock);
return sections.map((section, index) => {
const sectionText = section.text.trim() || titleBlock;
const normalizedContent = normalizeForSearch(sectionText);
return {
chunkId: `${node.id}#${index}`,
nodeId: node.id,
docTitle: node.name || node.id,
docPath: node.filePath || node.id,
sectionTitle: section.heading,
content: sectionText,
normalizedContent,
compactContent: compactKey(sectionText),
tags: node.tags || [],
summary: node.summary || "",
chunkType: detectChunkType(section.heading, sectionText),
layer: nodeLayer(node),
};
});
});
}
function countOccurrences(haystack, needle) {
if (!needle) return 0;
return haystack.split(needle).length - 1;
}
function fieldScore(field, query, fieldName, multiplier = 1) {
const normalized = normalizeForSearch(field);
const compact = compactKey(field);
const reasons = [];
let score = 0;
const rawNeedles = unique([query.normalized, String(query.raw || "").toLowerCase()].filter((v) => v.length >= 2));
for (const needle of rawNeedles) {
if (normalized.includes(needle)) {
const add = 70 * multiplier;
score += add;
reasons.push({ type: "exact", field: fieldName, message: `${fieldName} 原文命中:${needle}`, score: add });
}
}
if (query.compact && compact.includes(query.compact)) {
const add = 80 * multiplier;
score += add;
reasons.push({ type: "compact", field: fieldName, message: `${fieldName} 去标点命中`, score: add });
}
let tokenHits = 0;
for (const term of query.coreTerms) {
const normalizedTerm = normalizeForSearch(term);
const compactTerm = compactKey(term);
if (!normalizedTerm && !compactTerm) continue;
const occurrences = Math.max(countOccurrences(normalized, normalizedTerm), countOccurrences(compact, compactTerm));
if (occurrences > 0) {
tokenHits += 1;
score += Math.min(occurrences, 4) * Math.min(Math.max(compactTerm.length, normalizedTerm.length), 10) * multiplier;
}
}
if (tokenHits > 0) reasons.push({ type: "token", field: fieldName, message: `${fieldName} 关键词覆盖 ${tokenHits}`, score: tokenHits * multiplier });
return { score, reasons };
}
function semanticScore(chunk, query) {
const reasons = [];
const terms = unique([...query.tokens, ...query.expandedTerms.flatMap(tokenizeText)]).filter((term) => term.length >= 2);
if (!terms.length) return { score: 0, reasons };
const targetTokens = new Set(tokenizeText(`${chunk.docTitle} ${chunk.sectionTitle} ${chunk.normalizedContent}`));
let overlap = 0;
for (const term of terms) {
const compact = compactKey(term);
if (targetTokens.has(term) || chunk.compactContent.includes(compact) || compactKey(`${chunk.docTitle}${chunk.sectionTitle}`).includes(compact)) overlap += 1;
}
const ratio = overlap / Math.max(terms.length, 1);
const score = ratio * 90;
if (score > 0) reasons.push({ type: "semantic", message: `语义/同义词覆盖 ${(ratio * 100).toFixed(0)}%`, score });
return { score, reasons };
}
function directoryBoost(chunk, query) {
const p = chunk.docPath || "";
const base = p.includes("05_需求文档") ? 15 : p.includes("01_业务流程") ? 12 : p.includes("07_技术文档") ? 10 : p.includes("08_测试相关") ? 8 : p.includes("06_里程碑") ? 6 : p.includes("02_项目管理流程") ? 5 : p.includes("04_Agent检索") ? 4 : p.includes("03_规范与模板") ? 1 : 0;
if (query.questionType === "api" && p.includes("07_技术文档")) return base + 8;
if ((query.questionType === "definition" || query.questionType === "rule") && p.includes("05_需求文档")) return base + 5;
if (query.questionType === "test" && p.includes("08_测试相关")) return base + 12;
return base;
}
function chunkTypeBoost(chunk, query) {
if (query.questionType === "api" && chunk.chunkType === "api_contract") return 18;
if (query.questionType === "data_model" && chunk.chunkType === "data_model") return 18;
if (query.questionType === "process" && chunk.chunkType === "process") return 14;
if (query.questionType === "rule" && chunk.chunkType === "business_rule") return 14;
if (query.questionType === "definition" && (chunk.chunkType === "faq" || /概述|目标|说明/.test(chunk.sectionTitle))) return 10;
return 0;
}
function makeSnippet(text, query) {
const compact = String(text || "").replace(/\s+/g, " ").trim();
if (!compact) return "";
const lower = compact.toLowerCase();
const compactLower = compactKey(compact);
const terms = unique([query.normalized, query.raw, query.compact, ...query.coreTerms, ...query.expandedTerms]);
const hit = terms.map((term) => {
const plainIndex = lower.indexOf(normalizeForSearch(term));
if (plainIndex >= 0) return plainIndex;
const compactIndex = compactLower.indexOf(compactKey(term));
return compactIndex >= 0 ? Math.min(compactIndex, compact.length - 1) : -1;
}).filter((i) => i >= 0).sort((a, b) => a - b)[0];
const start = Math.max(0, (hit ?? 0) - 110);
const snippet = compact.slice(start, start + 420);
return `${start > 0 ? "…" : ""}${snippet}${start + 420 < compact.length ? "…" : ""}`;
}
function searchChunks(queryText, topK = 16) {
loadGraph();
const query = processQuery(queryText);
if (!query.normalized && !query.compact) return { query, hits: [] };
const weights = query.questionType === "api" ? { exact: 0.45, fuzzy: 0.25, semantic: 0.1 } : query.questionType === "definition" || query.questionType === "process" ? { exact: 0.25, fuzzy: 0.2, semantic: 0.3 } : { exact: 0.3, fuzzy: 0.2, semantic: 0.25 };
const hits = chunksCache.map((chunk) => {
const title = fieldScore(`${chunk.docTitle}\n${chunk.docPath}`, query, "docTitle", 4);
const section = fieldScore(chunk.sectionTitle, query, "sectionTitle", 6);
const content = fieldScore(chunk.normalizedContent, query, "content", 1);
const tags = fieldScore((chunk.tags || []).join(" "), query, "tags", 3);
const semantic = semanticScore(chunk, query);
const exact = title.score + section.score + tags.score + content.score;
const fuzzy = (chunk.compactContent.includes(query.compact) ? 80 : 0) + content.score * 0.2;
const titleBoost = title.score > 0 ? 12 : 0;
const sectionBoost = section.score > 0 ? 18 : 0;
const dirBoost = directoryBoost(chunk, query);
const typeBoost = chunkTypeBoost(chunk, query);
const score = exact * weights.exact + fuzzy * weights.fuzzy + semantic.score * weights.semantic + titleBoost + sectionBoost + dirBoost + typeBoost;
const reasons = [...title.reasons, ...section.reasons, ...tags.reasons, ...content.reasons.slice(0, 3), ...semantic.reasons];
if (dirBoost) reasons.push({ type: "directory", message: `目录权重:${(chunk.docPath || "").split("/")[0] || ""}`, score: dirBoost });
if (typeBoost) reasons.push({ type: "chunkType", message: `章节类型匹配:${chunk.chunkType}`, score: typeBoost });
return {
chunkId: chunk.chunkId,
nodeId: chunk.nodeId,
docTitle: chunk.docTitle,
docPath: chunk.docPath,
sectionTitle: chunk.sectionTitle,
chunkType: chunk.chunkType,
layer: chunk.layer,
score,
scores: { exact, fuzzy, semantic: semantic.score, titleBoost, sectionBoost, directoryBoost: dirBoost, chunkTypeBoost: typeBoost },
snippet: makeSnippet(chunk.content, query),
evidenceContent: chunk.content.slice(0, 2600),
reasons,
};
}).filter((hit) => hit.score > 10 && hit.snippet).sort((a, b) => b.score - a.score).reduce((acc, hit) => {
const key = `${hit.nodeId}:${hit.sectionTitle}`;
if (!acc.some((item) => `${item.nodeId}:${item.sectionTitle}` === key)) acc.push(hit);
return acc;
}, []).slice(0, topK);
return { query, hits };
}
function evidenceDecision(question, hits) {
if (!String(question || "").trim()) return { allowed: false, confidence: "none", reason: "请输入问题。" };
if (!hits.length) return { allowed: false, confidence: "none", reason: "没有检索到相关证据。" };
if (hits[0].score >= 75) return { allowed: true, confidence: "high", reason: "Top1 证据分数达到阈值。" };
if (hits[0].score >= 60 && (hits[1]?.score || 0) >= 50) return { allowed: true, confidence: "medium", reason: "多个证据片段达到中等相关。" };
return { allowed: false, confidence: "low", reason: "证据分数不足。" };
}
function localAnswer(question, hits) {
const decision = evidenceDecision(question, hits);
if (!decision.allowed) return "暂无该需求描述。";
const top = hits.slice(0, 5);
return [
`结论:已基于本地知识库检索到可用证据。${decision.reason}`,
"",
"相关依据:",
...top.map((hit, index) => `${index + 1}. ${hit.docTitle}${hit.sectionTitle ? ` / ${hit.sectionTitle}` : ""}\n ${hit.snippet}\n 来源:${hit.docPath}`),
].join("\n");
}
function detectProvider({ endpoint, apiKey, provider }) {
if (provider && provider !== "auto") return provider;
const raw = String(endpoint || "").toLowerCase();
if (String(apiKey || "").startsWith("plan-") || raw.includes("/plan/v1")) return "routin-plan";
return "openai";
}
function normalizeOpenAiEndpoint(endpoint) {
const raw = String(endpoint || "").trim();
if (!raw) return "";
if (/\/chat\/completions\/?$/.test(raw)) return raw;
return `${raw.replace(/\/+$/, "")}/chat/completions`;
}
function normalizePlanEndpoint(endpoint) {
const raw = String(endpoint || "").trim();
let base = raw || "https://api.routin.ai/plan/v1";
base = base.replace(/\/chat\/completions\/?$/, "").replace(/\/messages\/?$/, "").replace(/\/+$/, "");
if (!base.includes("/plan/v1")) base = base.replace(/\/v1$/, "/plan/v1");
return `${base}/messages`;
}
function extractPlanMessageText(data) {
if (data && typeof data === "object") {
const content = data.content;
if (Array.isArray(content)) {
const texts = content
.map((part) => typeof part === "string" ? part : part?.text)
.filter((part) => typeof part === "string" && part.trim());
if (texts.length) return texts.join("");
}
if (typeof content === "string") return content;
}
return "";
}
function extractAnswer(data, protocol) {
if (protocol === "routin-plan") {
const planText = extractPlanMessageText(data);
if (planText) return planText;
}
return data?.choices?.[0]?.message?.content
|| data?.message?.content
|| data?.response
|| data?.data?.answer
|| data?.answer
|| extractPlanMessageText(data);
}
async function callLlm({ endpoint, model, apiKey, provider = "auto", question, hits, timeoutMs = 60000 }) {
const protocol = detectProvider({ endpoint, apiKey, provider });
const requestEndpoint = protocol === "routin-plan" ? normalizePlanEndpoint(endpoint) : normalizeOpenAiEndpoint(endpoint);
const evidence = hits.slice(0, 8).map((hit, index) => `[${index + 1}]\n来源:${hit.docPath}\n章节:${hit.sectionTitle}\n内容:${hit.snippet}`).join("\n\n");
const systemPrompt = "你是如愿知识库问答助手。你只能基于提供的知识库片段回答,不得使用外部知识,不得编造文档中没有的内容。如果证据不足,请只回答:暂无该需求描述。回答末尾必须列出来源文件。";
const userPrompt = `用户问题:\n${question}\n\n检索证据:\n${evidence}\n\n请用中文结构化回答。`;
const messages = protocol === "routin-plan"
? [{ role: "user", content: `${systemPrompt}\n\n${userPrompt}` }]
: [{ role: "system", content: systemPrompt }, { role: "user", content: userPrompt }];
const body = protocol === "routin-plan"
? { model, messages, max_tokens: 4096, temperature: 0.7 }
: { model, messages, temperature: 0.1, stream: false };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const response = await fetch(requestEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
});
const text = await response.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch {}
if (!response.ok) {
const remoteMessage = data?.error?.message || data?.message || text.slice(0, 500);
const err = new Error(`模型接口返回 ${response.status} ${response.statusText}${remoteMessage ? `${remoteMessage}` : ""}`);
err.status = response.status;
err.endpoint = requestEndpoint;
err.elapsedMs = Date.now() - startedAt;
err.protocol = protocol;
throw err;
}
const answer = extractAnswer(data, protocol);
if (typeof answer !== "string" || !answer.trim()) {
const err = new Error("模型返回成功,但响应结构里没有可解析的回答内容。支持 OpenAI choices[0].message.content、RoutIn content[]/content、message.content / response / answer。原始返回" + text.slice(0, 500));
err.endpoint = requestEndpoint;
err.elapsedMs = Date.now() - startedAt;
err.protocol = protocol;
throw err;
}
return { answer: answer.trim(), endpoint: requestEndpoint, elapsedMs: Date.now() - startedAt, rawShape: Object.keys(data || {}), protocol };
} finally {
clearTimeout(timer);
}
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
async function handleApi(req, res, url) {
if (req.method === "OPTIONS") return sendJson(res, 200, { ok: true });
if (url.pathname === "/api/rag/health") {
const graph = loadGraph();
return sendJson(res, 200, { ok: true, graphPath, staticRoot, documentCount: (graph.nodes || []).length, chunkCount: chunksCache.length, llmProxy: true });
}
if (url.pathname === "/api/rag/search" && req.method === "POST") {
const body = await readBody(req);
const { query, hits } = searchChunks(body.query || "", body.topK || 16);
const decision = evidenceDecision(body.query || "", hits);
return sendJson(res, 200, { ok: true, query, decision, answer: localAnswer(body.query || "", hits), hits });
}
if (url.pathname === "/api/rag/answer" && req.method === "POST") {
const body = await readBody(req);
const requestStatus = { stage: "search", startedAt: new Date().toISOString() };
const { query, hits } = searchChunks(body.query || "", body.topK || 16);
const decision = evidenceDecision(body.query || "", hits);
if (!decision.allowed) return sendJson(res, 200, { ok: true, mode: "llm", requestStatus: { ...requestStatus, stage: "evidence_rejected" }, query, decision, answer: "暂无该需求描述。", hits });
if (!body.llm?.enabled) return sendJson(res, 200, { ok: true, mode: "local", requestStatus: { ...requestStatus, stage: "local_answer" }, query, decision, answer: localAnswer(body.query || "", hits), hits });
try {
const result = await callLlm({ endpoint: body.llm.endpoint, model: body.llm.model, apiKey: body.llm.apiKey, question: body.query || "", hits });
return sendJson(res, 200, { ok: true, mode: "llm", requestStatus: { ...requestStatus, stage: "llm_done", endpoint: result.endpoint, elapsedMs: result.elapsedMs, rawShape: result.rawShape }, query, decision, answer: result.answer, hits });
} catch (error) {
return sendJson(res, 502, {
ok: false,
mode: "llm",
requestStatus: { ...requestStatus, stage: "llm_failed", endpoint: error.endpoint || (detectProvider({ endpoint: body.llm?.endpoint, apiKey: body.llm?.apiKey, provider: body.llm?.provider }) === "routin-plan" ? normalizePlanEndpoint(body.llm?.endpoint) : normalizeOpenAiEndpoint(body.llm?.endpoint)), elapsedMs: error.elapsedMs, protocol: error.protocol },
query,
decision,
answer: localAnswer(body.query || "", hits),
hits,
error: {
message: error instanceof Error ? error.message : String(error),
name: error?.name,
status: error?.status,
},
});
}
}
if (url.pathname === "/api/llm/test" && req.method === "POST") {
const body = await readBody(req);
try {
const result = await callLlm({ endpoint: body.endpoint, model: body.model, apiKey: body.apiKey, question: "连接测试", hits: [{ docPath: "测试", sectionTitle: "连接测试", snippet: "请只回答:连接成功。", score: 100 }] });
return sendJson(res, 200, { ok: true, endpoint: result.endpoint, elapsedMs: result.elapsedMs, answer: result.answer });
} catch (error) {
return sendJson(res, 502, { ok: false, endpoint: error.endpoint || (detectProvider({ endpoint: body.endpoint, apiKey: body.apiKey, provider: body.provider }) === "routin-plan" ? normalizePlanEndpoint(body.endpoint) : normalizeOpenAiEndpoint(body.endpoint)), protocol: error.protocol, elapsedMs: error.elapsedMs, error: { message: error instanceof Error ? error.message : String(error), name: error?.name, status: error?.status } });
}
}
return sendJson(res, 404, { ok: false, error: "API not found" });
}
function serveStatic(req, res, url) {
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/") pathname = "/index.html";
const requested = path.normalize(path.join(staticRoot, pathname));
if (!requested.startsWith(staticRoot)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const filePath = fs.existsSync(requested) && fs.statSync(requested).isFile() ? requested : path.join(staticRoot, "index.html");
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Cache-Control": ext === ".html" ? "no-store" : "no-cache" });
fs.createReadStream(filePath).pipe(res);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
try {
if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
return serveStatic(req, res, url);
} catch (error) {
return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
server.listen(port, host, () => {
loadGraph();
console.log(`RAG dashboard server listening on http://${host}:${port}`);
console.log(`Static root: ${staticRoot}`);
console.log(`Graph path: ${graphPath}`);
});

View File

@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
const root = 'D:/AIcoding/WishFulfilled/知识库/under-anything';
const wiki = `${root}/wishfulfilled-wiki`;
const reqDir = `${wiki}/05_需求文档`;
const graphPaths = [
`${wiki}/.understand-anything/knowledge-graph.json`,
`${root}/wishfulfilled-dashboard/knowledge-graph.json`,
];
const metaPaths = [
`${wiki}/.understand-anything/meta.json`,
`${root}/wishfulfilled-dashboard/meta.json`,
];
const files = fs.readdirSync(reqDir)
.filter((name) => /\.(md|html?)$/i.test(name))
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function cleanLineNumbers(text) {
const lines = text.split(/\r?\n/);
let changed = 0;
const cleaned = lines.map((line) => {
const match = line.match(/^\s*\d+\|(.+)$/);
if (!match) return line;
changed += 1;
return match[1];
});
return changed >= Math.max(3, Math.floor(lines.length / 4)) ? cleaned.join('\n') : text;
}
function stripHtml(text) {
return text
.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
}
function titleFor(fileName, text) {
if (/\.html?$/i.test(fileName)) {
const match = text.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = match ? stripHtml(match[1]) : '';
if (title) return title;
}
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith('#')) return trimmed.replace(/^#+\s*/, '').trim();
}
return path.basename(fileName, path.extname(fileName)).replace(/^\d+[-_]/, '').replace(/_/g, ' ');
}
function summaryFor(fileName, text) {
const plain = /\.html?$/i.test(fileName)
? stripHtml(text)
: text.replace(/[`*_>#|\-\[\]()]/g, ' ').replace(/\s+/g, ' ').trim();
return plain ? plain.slice(0, 180) : '需求文档。';
}
function tagsFor(text) {
const tags = ['05_需求文档', '需求文档'];
const match = text.match(/^tags:\s*\[(.*?)\]/m);
if (!match) return tags;
for (const item of match[1].split(/[,]/)) {
const tag = item.trim().replace(/^['"]|['"]$/g, '');
if (tag && !tags.includes(tag)) tags.push(tag);
}
return tags;
}
function complexityFor(text) {
if (text.length > 20000) return 'complex';
if (text.length > 5000) return 'moderate';
return 'simple';
}
function updateGraph(graphPath) {
const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8'));
graph.nodes ??= [];
graph.edges ??= [];
graph.layers ??= [];
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
const edgeKeys = new Set(graph.edges.map((edge) => `${edge.source}|${edge.target}|${edge.type}`));
let layer = graph.layers.find((item) => item.id === 'layer-requirements');
if (!layer) {
layer = {
id: 'layer-requirements',
name: '需求文档',
description: '所有正式需求、业务规则、需求变更和需求索引。',
nodeIds: ['flow:layer-requirements'],
};
graph.layers.push(layer);
}
layer.nodeIds ??= [];
let added = 0;
let updated = 0;
for (const fileName of files) {
const absolutePath = `${reqDir}/${fileName}`;
const relPath = `05_需求文档/${fileName}`;
const nodeId = `doc:${relPath.replace(/\.[^.]+$/, '')}`;
const text = cleanLineNumbers(readText(absolutePath));
const node = {
id: nodeId,
type: 'document',
name: titleFor(fileName, text),
filePath: relPath,
summary: summaryFor(fileName, text),
tags: tagsFor(text),
complexity: complexityFor(text),
knowledgeMeta: {
content: text,
wikilinks: [...text.matchAll(/\[\[([^\]]+)\]\]/g)].map((match) => match[1]),
category: 'layer-requirements',
},
};
if (byId.has(nodeId)) {
Object.assign(byId.get(nodeId), node);
updated += 1;
} else {
graph.nodes.push(node);
byId.set(nodeId, node);
added += 1;
}
if (!layer.nodeIds.includes(nodeId)) layer.nodeIds.push(nodeId);
const edgeKey = `flow:layer-requirements|${nodeId}|documents`;
if (!edgeKeys.has(edgeKey)) {
graph.edges.push({
source: 'flow:layer-requirements',
target: nodeId,
type: 'documents',
direction: 'forward',
description: '本层文档',
weight: 0.65,
});
edgeKeys.add(edgeKey);
}
}
const count = layer.nodeIds.filter((id) => id !== 'flow:layer-requirements').length;
const flow = byId.get('flow:layer-requirements');
if (flow) {
flow.summary = '所有正式需求、业务规则、需求变更和需求索引。点击本层可查看全部需求文档并检索。';
flow.knowledgeMeta ??= {};
flow.knowledgeMeta.content = `# 需求文档\n\n所有正式需求、业务规则、需求变更和需求索引。点击本层可查看全部需求文档并检索。\n\n本层包含 ${count} 个文档。点击右侧 Files 或在本层详情中选择具体文档查看内容。`;
flow.knowledgeMeta.category = 'layer-requirements';
}
graph.project ??= {};
graph.project.analyzedAt = new Date().toISOString();
fs.writeFileSync(graphPath, `${JSON.stringify(graph, null, 2)}\n`, 'utf8');
return { graphPath, added, updated, requirements: count, nodes: graph.nodes.length };
}
const results = graphPaths.map(updateGraph);
for (const metaPath of metaPaths) {
if (!fs.existsSync(metaPath)) continue;
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const graph = JSON.parse(fs.readFileSync(`${path.dirname(metaPath)}/knowledge-graph.json`, 'utf8'));
meta.lastAnalyzedAt = new Date().toISOString();
meta.analyzedFiles = graph.nodes.filter((node) => String(node.id).startsWith('doc:')).length;
fs.writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
}
console.log(JSON.stringify(results, null, 2));

View File

@@ -0,0 +1,749 @@
import { useEffect, useState, useMemo, useCallback, lazy, Suspense } from "react";
import { validateGraph } from "@understand-anything/core/schema";
import type { GraphIssue } from "@understand-anything/core/schema";
import { useDashboardStore } from "./store";
import GraphView from "./components/GraphView";
import DomainGraphView from "./components/DomainGraphView";
import KnowledgeGraphView from "./components/KnowledgeGraphView";
import SearchBar from "./components/SearchBar";
import NodeInfo from "./components/NodeInfo";
import LayerLegend from "./components/LayerLegend";
import DiffToggle from "./components/DiffToggle";
import FilterPanel from "./components/FilterPanel";
import ExportMenu from "./components/ExportMenu";
import PersonaSelector from "./components/PersonaSelector";
import ProjectOverview from "./components/ProjectOverview";
import FileExplorer from "./components/FileExplorer";
import WarningBanner from "./components/WarningBanner";
import TokenGate from "./components/TokenGate";
import MobileLayout from "./components/MobileLayout";
import { useIsMobile } from "./hooks/useIsMobile";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
import { ThemeProvider } from "./themes/index.ts";
import { ThemePicker } from "./components/ThemePicker.tsx";
import type { ThemeConfig } from "./themes/index.ts";
import { I18nProvider, useI18n } from "./contexts/I18nContext.tsx";
// Lazy-load heavy / optional components so they ship in separate chunks.
const CodeViewer = lazy(() => import("./components/CodeViewer"));
const LearnPanel = lazy(() => import("./components/LearnPanel"));
const PathFinderModal = lazy(() => import("./components/PathFinderModal"));
const KeyboardShortcutsHelp = lazy(
() => import("./components/KeyboardShortcutsHelp"),
);
const RagAssistant = lazy(() => import("./components/RagAssistant"));
const OnboardingOverlay = lazy(() => import("./components/OnboardingOverlay"));
const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === "true";
const SESSION_TOKEN_KEY = "understand-anything-token";
const ONBOARDING_DISMISSED_KEY = "ua-onboarding-dismissed-v1";
type SidebarTab = "info" | "files";
function shouldShowOnboarding(): boolean {
if (typeof window === "undefined") return false;
const params = new URLSearchParams(window.location.search);
if (params.get("onboard") === "force") return true;
return window.localStorage.getItem(ONBOARDING_DISMISSED_KEY) !== "1";
}
/** Resolve data file URL — in demo mode, use env var URLs; otherwise use local paths with token. */
function dataUrl(fileName: string, token: string | null): string {
if (DEMO_MODE) {
const envMap: Record<string, string | undefined> = {
"knowledge-graph.json": import.meta.env.VITE_GRAPH_URL,
"domain-graph.json": import.meta.env.VITE_DOMAIN_GRAPH_URL,
"meta.json": import.meta.env.VITE_META_URL,
"diff-overlay.json": import.meta.env.VITE_DIFF_OVERLAY_URL,
"config.json": import.meta.env.VITE_CONFIG_URL,
};
const url = envMap[fileName];
if (url) return url;
}
const path = `/${fileName}`;
return token ? `${path}?token=${encodeURIComponent(token)}` : path;
}
/**
* Resolve the access token from the URL query string or sessionStorage.
* If found in the URL, persist to sessionStorage and strip the param from the address bar.
*/
function resolveInitialToken(): string | null {
if (DEMO_MODE) return "__demo__";
const params = new URLSearchParams(window.location.search);
const urlToken = params.get("token");
if (urlToken) {
sessionStorage.setItem(SESSION_TOKEN_KEY, urlToken);
// Clean the URL
params.delete("token");
const cleanSearch = params.toString();
const newUrl =
window.location.pathname + (cleanSearch ? `?${cleanSearch}` : "") + window.location.hash;
window.history.replaceState(null, "", newUrl);
return urlToken;
}
return sessionStorage.getItem(SESSION_TOKEN_KEY);
}
function App() {
const [accessToken, setAccessToken] = useState<string | null>(resolveInitialToken);
const handleTokenValid = useCallback((token: string) => {
sessionStorage.setItem(SESSION_TOKEN_KEY, token);
setAccessToken(token);
}, []);
// In demo mode, skip token gate entirely
if (DEMO_MODE) {
return <Dashboard accessToken="__demo__" />;
}
// Show the token gate when no token is available
if (accessToken === null) {
return <TokenGate onTokenValid={handleTokenValid} />;
}
return <Dashboard accessToken={accessToken} />;
}
function Dashboard({ accessToken }: { accessToken: string }) {
const setGraph = useDashboardStore((s) => s.setGraph);
const setDomainGraph = useDashboardStore((s) => s.setDomainGraph);
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
const [loadError, setLoadError] = useState<string | null>(null);
const [graphIssues, setGraphIssues] = useState<GraphIssue[]>([]);
const [metaTheme, setMetaTheme] = useState<ThemeConfig | null>(null);
const [outputLanguage, setOutputLanguage] = useState<string | undefined>();
useEffect(() => {
fetch(dataUrl("meta.json", accessToken))
.then((r) => (r.ok ? r.json() : null))
.then((meta) => {
if (meta?.theme) setMetaTheme(meta.theme);
})
.catch(() => {});
fetch(dataUrl("config.json", accessToken))
.then((r) => (r.ok ? r.json() : null))
.then((config) => {
if (config?.outputLanguage) setOutputLanguage(config.outputLanguage);
})
.catch(() => {});
}, []);
useEffect(() => {
fetch(dataUrl("knowledge-graph.json", accessToken))
.then((res) => res.json())
.then((data: unknown) => {
const result = validateGraph(data);
if (result.success && result.data) {
setGraph(result.data);
setGraphIssues(result.issues);
if ((data as Record<string, unknown>).kind === "knowledge") {
useDashboardStore.getState().setViewMode("knowledge");
useDashboardStore.getState().setIsKnowledgeGraph(true);
}
for (const issue of result.issues) {
if (issue.level === "auto-corrected") {
console.warn(`[graph] auto-corrected: ${issue.message}`);
} else if (issue.level === "dropped") {
console.error(`[graph] dropped: ${issue.message}`);
}
}
} else if (result.fatal) {
console.error("Knowledge graph validation failed:", result.fatal);
setLoadError(`Invalid knowledge graph: ${result.fatal}`);
} else {
console.error("Knowledge graph validation failed: unknown error");
setLoadError("Invalid knowledge graph: unknown validation error");
}
})
.catch((err) => {
console.error("Failed to load knowledge graph:", err);
setLoadError(`Failed to load knowledge graph: ${err instanceof Error ? err.message : String(err)}`);
});
}, [setGraph]);
useEffect(() => {
fetch(dataUrl("diff-overlay.json", accessToken))
.then((res) => {
if (!res.ok) return null;
return res.json();
})
.then((data: unknown) => {
if (
data &&
typeof data === "object" &&
"changedNodeIds" in data &&
"affectedNodeIds" in data &&
Array.isArray((data as Record<string, unknown>).changedNodeIds) &&
Array.isArray((data as Record<string, unknown>).affectedNodeIds)
) {
const d = data as { changedNodeIds: string[]; affectedNodeIds: string[] };
if (d.changedNodeIds.length > 0) {
setDiffOverlay(d.changedNodeIds, d.affectedNodeIds);
}
}
})
.catch(() => {});
}, [setDiffOverlay]);
useEffect(() => {
fetch(dataUrl("domain-graph.json", accessToken))
.then((res) => {
if (!res.ok) return null;
return res.json();
})
.then((data: unknown) => {
if (!data) return;
const result = validateGraph(data);
if (result.success && result.data) {
setDomainGraph(result.data);
} else if (result.fatal) {
console.warn(`[domain-graph] validation failed: ${result.fatal}`);
}
})
.catch(() => {});
}, [setDomainGraph]);
return (
<I18nProvider language={outputLanguage ?? "en"}>
<ThemeProvider metaTheme={metaTheme}>
<DashboardContent
accessToken={accessToken}
loadError={loadError}
graphIssues={graphIssues}
/>
</ThemeProvider>
</I18nProvider>
);
}
function DashboardContent({
accessToken,
loadError,
graphIssues,
}: {
accessToken: string;
loadError: string | null;
graphIssues: GraphIssue[];
}) {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const tourActive = useDashboardStore((s) => s.tourActive);
const persona = useDashboardStore((s) => s.persona);
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
const codeViewerExpanded = useDashboardStore((s) => s.codeViewerExpanded);
const expandCodeViewer = useDashboardStore((s) => s.expandCodeViewer);
const collapseCodeViewer = useDashboardStore((s) => s.collapseCodeViewer);
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter);
const detailLevel = useDashboardStore((s) => s.detailLevel);
const setDetailLevel = useDashboardStore((s) => s.setDetailLevel);
const showFunctionsInClassView = useDashboardStore((s) => s.showFunctionsInClassView);
const toggleShowFunctionsInClassView = useDashboardStore((s) => s.toggleShowFunctionsInClassView);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [showRagAssistant, setShowRagAssistant] = useState(false);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("info");
const [showOnboarding, setShowOnboarding] = useState(shouldShowOnboarding);
const dismissOnboarding = useCallback((remember: boolean) => {
if (remember && typeof window !== "undefined") {
window.localStorage.setItem(ONBOARDING_DISMISSED_KEY, "1");
}
setShowOnboarding(false);
}, []);
const viewMode = useDashboardStore((s) => s.viewMode);
const setViewMode = useDashboardStore((s) => s.setViewMode);
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const layoutIssues = useDashboardStore((s) => s.layoutIssues);
const isMobile = useIsMobile();
const { t } = useI18n();
const allIssues = useMemo(
() => [...graphIssues, ...layoutIssues],
[graphIssues, layoutIssues],
);
useEffect(() => {
if (selectedNodeId) setSidebarTab("info");
}, [selectedNodeId]);
// Define keyboard shortcuts
const shortcuts = useMemo<KeyboardShortcut[]>(
() => [
// Help
{
key: "?",
shiftKey: true,
description: t.keyboardShortcuts.showHelp,
action: () => setShowKeyboardHelp((prev) => !prev),
category: "General",
},
// Navigation
{
key: "Escape",
description: t.keyboardShortcuts.escapeDesc,
action: () => {
// Read from store at invocation time to avoid stale closures
const state = useDashboardStore.getState();
if (state.pathFinderOpen) {
state.togglePathFinder();
} else if (state.filterPanelOpen) {
state.toggleFilterPanel();
} else if (state.exportMenuOpen) {
state.toggleExportMenu();
} else if (state.codeViewerExpanded) {
state.collapseCodeViewer();
} else if (state.codeViewerOpen) {
state.closeCodeViewer();
} else if (state.selectedNodeId) {
state.selectNode(null);
} else if (state.navigationLevel === "layer-detail") {
state.navigateToOverview();
} else if (state.tourActive) {
state.stopTour();
} else {
setShowKeyboardHelp(false);
}
},
category: "Navigation",
},
{
key: "/",
description: t.keyboardShortcuts.focusSearch,
action: () => {
const searchInput = document.querySelector<HTMLInputElement>(
'[data-testid="search-input"]'
);
searchInput?.focus();
},
category: "Navigation",
},
// Tour controls
{
key: "ArrowRight",
description: t.keyboardShortcuts.nextStep,
action: () => {
const state = useDashboardStore.getState();
if (state.tourActive) {
state.nextTourStep();
}
},
category: "Tour",
},
{
key: "ArrowLeft",
description: t.keyboardShortcuts.prevStep,
action: () => {
const state = useDashboardStore.getState();
if (state.tourActive) {
state.prevTourStep();
}
},
category: "Tour",
},
// View toggles
{
key: "d",
description: t.keyboardShortcuts.toggleDiff,
action: () => {
const state = useDashboardStore.getState();
state.toggleDiffMode();
},
category: "View",
},
{
key: "f",
description: t.keyboardShortcuts.toggleFilter,
action: () => {
const state = useDashboardStore.getState();
state.toggleFilterPanel();
},
category: "View",
},
{
key: "e",
description: t.keyboardShortcuts.toggleExport,
action: () => {
const state = useDashboardStore.getState();
state.toggleExportMenu();
},
category: "View",
},
{
key: "p",
description: t.keyboardShortcuts.openPathFinder,
action: () => {
const state = useDashboardStore.getState();
state.togglePathFinder();
},
category: "View",
},
{
key: "r",
description: "打开知识库 RAG 问答",
action: () => setShowRagAssistant((prev) => !prev),
category: "View",
},
],
[t]
);
// Register keyboard shortcuts
useKeyboardShortcuts(shortcuts);
// Determine sidebar content
// NodeInfo always takes priority when a node is selected.
// Learn mode adds LearnPanel below it; otherwise ProjectOverview shows when idle.
const isLearnMode = tourActive || persona === "junior";
const infoSidebarContent = (
<>
{selectedNodeId && <NodeInfo />}
{isLearnMode && (
<Suspense fallback={null}>
<LearnPanel />
</Suspense>
)}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
const sidebarContent = (
<div className="h-full flex flex-col min-h-0">
<div className="flex items-center gap-1 p-2 border-b border-border-subtle bg-surface shrink-0">
{(["info", "files"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setSidebarTab(tab)}
className={`flex-1 px-3 py-1.5 rounded-md text-xs font-semibold uppercase tracking-wider transition-colors ${
sidebarTab === tab
? "bg-accent/15 text-accent"
: "text-text-muted hover:text-text-primary hover:bg-elevated"
}`}
>
{tab === "info" ? t.sidebar.info : t.sidebar.files}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{sidebarTab === "files" ? <FileExplorer /> : infoSidebarContent}
</div>
</div>
);
if (isMobile) {
return (
<MobileLayout
accessToken={accessToken}
showKeyboardHelp={showKeyboardHelp}
setShowKeyboardHelp={setShowKeyboardHelp}
loadError={loadError}
allIssues={allIssues}
shortcuts={shortcuts}
/>
);
}
return (
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Header */}
<header className="flex items-center px-3 sm:px-5 py-3 bg-surface border-b border-border-subtle shrink-0 gap-2 sm:gap-4">
{/* Left — fixed */}
<div className="flex items-center gap-3 sm:gap-5 shrink-0 min-w-0">
<h1 className="font-heading text-base sm:text-lg text-text-primary tracking-wide truncate max-w-[160px] sm:max-w-[220px] lg:max-w-none">
{graph?.project.name ?? t.common.appName}
</h1>
<div className="w-px h-5 bg-border-subtle hidden sm:block" />
<PersonaSelector />
{graph && !isKnowledgeGraph && domainGraph && (
<>
<div className="w-px h-5 bg-border-subtle" />
<div className="flex items-center bg-elevated rounded-lg p-0.5">
<button
type="button"
onClick={() => setViewMode("domain")}
title={t.drawer.domain}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
viewMode === "domain"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.drawer.domain}
</button>
<button
type="button"
onClick={() => setViewMode("structural")}
title={t.drawer.structural}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
viewMode === "structural"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.drawer.structural}
</button>
</div>
</>
)}
</div>
{/* Middle — scrollable legends */}
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-4 w-max">
<DiffToggle />
{/* Detail level: file view (architecture) / class view (code structure) */}
{!isKnowledgeGraph && viewMode !== "domain" && (
<>
<div className="w-px h-5 bg-border-subtle" />
<div className="flex items-center bg-elevated rounded-lg p-0.5">
<button
type="button"
onClick={() => setDetailLevel("file")}
title={t.detailLevel.filesTitle}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
detailLevel === "file"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.detailLevel.files}
</button>
<button
type="button"
onClick={() => setDetailLevel("class")}
title={t.detailLevel.classesTitle}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
detailLevel === "class"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.detailLevel.classes}
</button>
</div>
{detailLevel === "class" && (
<button
type="button"
onClick={toggleShowFunctionsInClassView}
title={t.detailLevel.fnTitle}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors ${
showFunctionsInClassView
? "border-amber-500/50 bg-amber-500/10 text-amber-400"
: "border-border-medium bg-elevated text-text-muted hover:text-text-secondary"
}`}
>
{t.detailLevel.fn}
</button>
)}
</>
)}
<div className="flex items-center gap-1">
{(isKnowledgeGraph ? [
{ key: "knowledge" as const, label: t.nodeTypeLabels.all, color: "var(--color-node-article)" },
] : [
{ key: "code" as const, label: t.nodeTypeLabels.code, color: "var(--color-node-file)" },
{ key: "config" as const, label: t.nodeTypeLabels.config, color: "var(--color-node-config)" },
{ key: "docs" as const, label: t.nodeTypeLabels.docs, color: "var(--color-node-document)" },
{ key: "infra" as const, label: t.nodeTypeLabels.infra, color: "var(--color-node-service)" },
{ key: "data" as const, label: t.nodeTypeLabels.data, color: "var(--color-node-table)" },
{ key: "domain" as const, label: t.nodeTypeLabels.domain, color: "var(--color-node-concept)" },
{ key: "knowledge" as const, label: t.nodeTypeLabels.knowledge, color: "var(--color-node-article)" },
]).map((cat) => (
<button
key={cat.key}
onClick={() => toggleNodeTypeFilter(cat.key)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 whitespace-nowrap ${
nodeTypeFilters[cat.key] !== false
? "border-border-medium bg-elevated text-text-secondary hover:text-text-primary"
: "border-transparent bg-transparent text-text-muted/40 line-through hover:text-text-muted"
}`}
title={`${nodeTypeFilters[cat.key] !== false ? "Hide" : "Show"} ${cat.label} nodes`}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: cat.color,
opacity: nodeTypeFilters[cat.key] !== false ? 1 : 0.3,
}}
/>
{cat.label}
</button>
))}
</div>
<LayerLegend />
</div>
</div>
{/* Right — fixed actions */}
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<FilterPanel />
<ExportMenu />
<button
type="button"
onClick={() => setShowRagAssistant(true)}
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
title="知识库 RAG 问答"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h8M8 14h5m8-2a9 9 0 11-3.219-6.89L21 5v7z"
/>
</svg>
<span className="hidden md:inline">RAG</span>
</button>
<button
onClick={togglePathFinder}
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
title={t.pathFinder.title}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
<span className="hidden md:inline">{t.common.path}</span>
</button>
<ThemePicker />
<button
onClick={() => setShowKeyboardHelp(true)}
className="text-text-muted hover:text-accent transition-colors"
title={t.keyboardShortcuts.showHelp}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
</div>
</header>
{/* Search */}
<SearchBar />
{/* Validation warning banner */}
{allIssues.length > 0 && !loadError && (
<WarningBanner issues={allIssues} />
)}
{/* Error banner */}
{loadError && (
<div className="px-5 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
{loadError}
</div>
)}
{/* Main content: Graph + Sidebar */}
<div className="flex-1 flex min-h-0 relative">
{/* Graph area */}
<div className="flex-1 min-w-0 min-h-0 relative">
{viewMode === "knowledge" ? (
<KnowledgeGraphView />
) : viewMode === "domain" && domainGraph ? (
<DomainGraphView />
) : (
<GraphView />
)}
<div className="absolute top-3 right-3 text-sm text-text-muted/60 pointer-events-none select-none">
{t.common.pressKeyboard}
</div>
</div>
{/* Right sidebar — telescopes at narrower widths */}
<aside className="w-[260px] md:w-[300px] lg:w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
{sidebarContent}
</aside>
{/* Code viewer slide-up overlay (collapsed state) */}
{codeViewerOpen && !codeViewerExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-[40vh] bg-surface border-t border-border-subtle animate-slide-up z-20 overflow-hidden">
<Suspense fallback={null}>
<CodeViewer accessToken={accessToken} onExpand={expandCodeViewer} />
</Suspense>
</div>
)}
</div>
{/* Expanded code viewer modal */}
{codeViewerOpen && codeViewerExpanded && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 backdrop-blur-sm p-4 sm:p-6"
onMouseDown={collapseCodeViewer}
>
<div
className="w-[calc(100vw-32px)] max-w-[1120px] h-[calc(100vh-32px)] sm:h-[calc(100vh-48px)] max-h-[820px] rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
onMouseDown={(event) => event.stopPropagation()}
>
<Suspense fallback={null}>
<CodeViewer
accessToken={accessToken}
presentation="modal"
onClose={collapseCodeViewer}
/>
</Suspense>
</div>
</div>
)}
{/* Keyboard shortcuts help modal */}
{showKeyboardHelp && (
<Suspense fallback={null}>
<KeyboardShortcutsHelp
shortcuts={shortcuts}
onClose={() => setShowKeyboardHelp(false)}
/>
</Suspense>
)}
{/* RAG Assistant Modal */}
{showRagAssistant && (
<Suspense fallback={null}>
<RagAssistant onClose={() => setShowRagAssistant(false)} />
</Suspense>
)}
{/* Path Finder Modal — only mounted when open so its chunk is lazy-loaded on demand. */}
{pathFinderOpen && (
<Suspense fallback={null}>
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
</Suspense>
)}
{/* First-visit onboarding overlay — only mounted when needed so its chunk is lazy-loaded on demand. */}
{showOnboarding && (
<Suspense fallback={null}>
<OnboardingOverlay onDismiss={dismissOnboarding} />
</Suspense>
)}
</div>
);
}
export default App;

View File

@@ -0,0 +1,40 @@
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
export default function Breadcrumb() {
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const graph = useDashboardStore((s) => s.graph);
const navigateToOverview = useDashboardStore((s) => s.navigateToOverview);
const { t } = useI18n();
const activeLayer = graph?.layers.find((l) => l.id === activeLayerId);
return (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
{navigationLevel === "overview" && (
<div className="px-4 py-2 rounded-full bg-elevated border border-border-subtle text-xs font-semibold tracking-wider uppercase text-text-secondary shadow-lg">
{t.breadcrumb.projectOverview}
</div>
)}
{navigationLevel === "layer-detail" && (
<div className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-elevated border border-gold/30 text-xs font-semibold tracking-wider uppercase shadow-lg">
<button
onClick={navigateToOverview}
className="text-gold hover:text-gold-bright transition-colors"
>
{t.breadcrumb.project}
</button>
<span className="text-text-muted"></span>
<span className="text-text-primary">
{activeLayer?.name ?? t.layer.defaultName}
</span>
<span className="text-text-muted ml-1 text-[10px] normal-case tracking-normal">
({t.breadcrumb.escBack})
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,290 @@
import { useEffect, useMemo, useState } from "react";
import { Highlight, themes } from "prism-react-renderer";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
interface CodeViewerProps {
accessToken: string;
presentation?: "sidebar" | "modal";
onClose?: () => void;
onExpand?: () => void;
}
interface SourceFile {
path: string;
language: string;
content: string;
sizeBytes: number;
lineCount: number;
}
type SourceState =
| { status: "idle" | "loading"; source: null; error: null }
| { status: "loaded"; source: SourceFile; error: null }
| { status: "error"; source: null; error: string };
interface EmbeddedContentNode {
filePath?: string;
knowledgeMeta?: {
content?: string;
};
}
function fileContentUrl(filePath: string, token: string): string {
const params = new URLSearchParams({ token, path: filePath });
return `/file-content.json?${params.toString()}`;
}
function sourceFromEmbeddedContent(node: EmbeddedContentNode): SourceFile | null {
const content = node.knowledgeMeta?.content;
if (typeof content !== "string") return null;
return {
path: node.filePath ?? "",
language: fallbackLanguage(node.filePath),
content,
sizeBytes: new TextEncoder().encode(content).byteLength,
lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length,
};
}
function fallbackLanguage(filePath: string | undefined): string {
const ext = filePath?.split(".").pop()?.toLowerCase();
const byExt: Record<string, string> = {
css: "css",
go: "go",
html: "markup",
js: "javascript",
jsx: "jsx",
json: "json",
md: "markdown",
py: "python",
rb: "ruby",
rs: "rust",
sh: "bash",
ts: "typescript",
tsx: "tsx",
yaml: "yaml",
yml: "yaml",
};
return ext ? byExt[ext] ?? "text" : "text";
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function CodeViewer({
accessToken,
presentation = "sidebar",
onClose,
onExpand,
}: CodeViewerProps) {
const graph = useDashboardStore((s) => s.graph);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const viewMode = useDashboardStore((s) => s.viewMode);
const codeViewerNodeId = useDashboardStore((s) => s.codeViewerNodeId);
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
const activeGraph = viewMode === "domain" && domainGraph ? domainGraph : graph;
// Files tab always builds its tree from the structural graph, so a node ID opened from
// there may not exist in the active (domain) graph — fall back to the structural graph.
const node =
activeGraph?.nodes.find((n) => n.id === codeViewerNodeId) ??
graph?.nodes.find((n) => n.id === codeViewerNodeId) ??
null;
const [state, setState] = useState<SourceState>({
status: "idle",
source: null,
error: null,
});
const { t } = useI18n();
useEffect(() => {
if (!node?.filePath) {
setState({ status: "error", source: null, error: "This node does not have a file path." });
return;
}
const embeddedSource = sourceFromEmbeddedContent(node);
if (embeddedSource) {
setState({ status: "loaded", source: embeddedSource, error: null });
return;
}
if (accessToken === "__demo__") {
setState({
status: "error",
source: null,
error: "Source preview is available only when the local dashboard server is running.",
});
return;
}
const controller = new AbortController();
setState({ status: "loading", source: null, error: null });
fetch(fileContentUrl(node.filePath, accessToken), { signal: controller.signal })
.then(async (res) => {
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
throw new Error("Source endpoint returned non-JSON content. Run the local dashboard server or use embedded graph content.");
}
const data = (await res.json()) as SourceFile | { error?: string };
if (!res.ok) {
throw new Error("error" in data && data.error ? data.error : "Source unavailable");
}
setState({ status: "loaded", source: data as SourceFile, error: null });
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setState({
status: "error",
source: null,
error: err instanceof Error ? err.message : String(err),
});
});
return () => controller.abort();
}, [accessToken, node]);
const highlightedRange = useMemo(() => {
if (!node?.lineRange) return null;
return { start: node.lineRange[0], end: node.lineRange[1] };
}, [node?.lineRange]);
if (!node) {
return (
<div className="h-full w-full flex items-center justify-center bg-surface">
<p className="text-text-muted text-sm">{t.codeViewer.noFile}</p>
</div>
);
}
const source = state.source;
const language = source?.language ?? fallbackLanguage(node.filePath);
const lineInfo = highlightedRange
? `${t.codeViewer.lines} ${highlightedRange.start}-${highlightedRange.end}`
: t.codeViewer.fullFile;
const isModal = presentation === "modal";
const handleClose = onClose ?? closeCodeViewer;
return (
<div className="h-full w-full flex flex-col bg-surface overflow-hidden">
<div className="flex items-start gap-3 px-4 py-3 bg-elevated border-b border-border-subtle shrink-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded border"
style={{
color: "var(--color-node-file)",
borderColor: "color-mix(in srgb, var(--color-node-file) 30%, transparent)",
backgroundColor: "color-mix(in srgb, var(--color-node-file) 10%, transparent)",
}}
>
{language}
</span>
<span className="text-[10px] text-text-muted">{lineInfo}</span>
</div>
<div className="text-sm font-heading text-text-primary truncate" title={node.name}>
{node.name}
</div>
{node.filePath && (
<div className="text-[11px] font-mono text-text-muted truncate mt-0.5" title={node.filePath}>
{node.filePath}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{onExpand && (
<button
type="button"
onClick={onExpand}
className="text-text-muted hover:text-text-primary transition-colors"
title={t.codeViewer.openLarger}
aria-label={t.codeViewer.openLarger}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 9V4h5M20 15v5h-5M4 4l6 6M20 20l-6-6" />
</svg>
</button>
)}
<button
type="button"
onClick={handleClose}
className="text-text-muted hover:text-text-primary transition-colors"
title={isModal ? t.codeViewer.closeExpanded : t.codeViewer.closeViewer}
aria-label={isModal ? t.codeViewer.closeExpanded : t.codeViewer.closeViewer}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto bg-root">
{state.status === "loading" && (
<div className="p-5 text-sm text-text-muted">{t.codeViewer.loading}</div>
)}
{state.status === "error" && (
<div className="p-5">
<div className="rounded-lg border border-border-subtle bg-elevated p-4">
<div className="text-sm font-medium text-text-primary mb-2">{t.codeViewer.sourceUnavailable}</div>
<p className="text-sm text-text-secondary leading-relaxed">{state.error}</p>
</div>
</div>
)}
{source && (
<>
<div className="px-4 py-2 border-b border-border-subtle bg-surface text-[11px] text-text-muted flex items-center justify-between">
<span>{source.lineCount} {t.codeViewer.linesLabel}</span>
<span>{formatBytes(source.sizeBytes)}</span>
</div>
<Highlight code={source.content} language={language} theme={themes.vsDark}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={`${className} min-w-max p-0 m-0 ${
isModal ? "text-xs leading-5" : "text-[11px] leading-5"
} font-mono`}
style={{ ...style, background: "transparent" }}
>
{tokens.map((line, index) => {
const lineNumber = index + 1;
const isHighlighted =
highlightedRange !== null &&
lineNumber >= highlightedRange.start &&
lineNumber <= highlightedRange.end;
const lineProps = getLineProps({ line });
return (
<div
key={lineNumber}
{...lineProps}
className={`${lineProps.className} flex ${
isHighlighted ? "bg-accent/15" : "hover:bg-elevated/40"
}`}
>
<span className="w-12 shrink-0 select-none border-r border-border-subtle pr-3 text-right text-text-muted bg-surface/60">
{lineNumber}
</span>
<span className="pl-3 pr-6 whitespace-pre">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
);
})}
</pre>
)}
</Highlight>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { memo } from "react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
export interface ContainerNodeData extends Record<string, unknown> {
containerId: string;
name: string;
childCount: number;
strategy: "folder" | "community";
colorIndex: number;
isExpanded: boolean;
hasSearchHits: boolean;
searchHitCount?: number;
isDiffAffected: boolean;
isFocusedViaChild: boolean;
onToggle: (containerId: string) => void;
}
export type ContainerFlowNode = Node<ContainerNodeData, "container">;
function ContainerNodeComponent({ data, width, height }: NodeProps<ContainerFlowNode>) {
const color = getLayerColor(data.colorIndex);
const borderColor = data.isDiffAffected
? "var(--color-diff-changed)"
: data.isExpanded || data.isFocusedViaChild
? "rgba(212,165,116,0.6)"
: "rgba(212,165,116,0.25)";
const borderWidth = data.isExpanded || data.isFocusedViaChild ? 1.5 : 1;
const labelDimmed = data.name === "~";
const labelText = labelDimmed ? "(root)" : data.name;
const handleToggle = (e: React.SyntheticEvent) => {
e.stopPropagation();
data.onToggle(data.containerId);
};
return (
<div
role="button"
tabIndex={0}
aria-expanded={data.isExpanded}
aria-label={`${labelText} container, ${data.childCount} item${data.childCount !== 1 ? "s" : ""}, ${data.isExpanded ? "expanded" : "collapsed"}`}
className="rounded-xl cursor-pointer transition-all focus:outline-none focus:ring-2 focus:ring-[rgba(212,165,116,0.6)]"
style={{
width,
height,
background: "rgba(255,255,255,0.02)",
border: `${borderWidth}px solid ${borderColor}`,
position: "relative",
}}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggle(e);
}
}}
>
<div
className="flex items-center justify-between font-heading"
style={{
padding: "12px 16px",
color: color.label,
fontSize: 14,
fontWeight: 400,
}}
>
<span
className={labelDimmed ? "opacity-50" : ""}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
{data.isExpanded && <span style={{ fontSize: 10 }}></span>}
{labelText}
{data.searchHitCount != null && data.searchHitCount > 0 && (
<span
className="font-mono"
style={{
marginLeft: 6,
fontSize: 10,
background: "rgba(212,165,116,0.2)",
color: "var(--color-gold, #d4a574)",
padding: "1px 6px",
borderRadius: 8,
}}
>
{data.searchHitCount} hit{data.searchHitCount !== 1 ? "s" : ""}
</span>
)}
</span>
<span style={{ color: "#a39787", fontSize: 11 }}>{data.childCount}</span>
</div>
</div>
);
}
const ContainerNode = memo(ContainerNodeComponent);
ContainerNode.displayName = "ContainerNode";
export default ContainerNode;

View File

@@ -0,0 +1,190 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import type { NodeType } from "@understand-anything/core/types";
import { useI18n } from "../contexts/I18nContext";
// Color maps keyed by NodeType — must be kept in sync with core NodeType union.
const typeColors: Record<NodeType, string> = {
file: "var(--color-node-file)",
function: "var(--color-node-function)",
class: "var(--color-node-class)",
module: "var(--color-node-module)",
concept: "var(--color-node-concept)",
config: "var(--color-node-config)",
document: "var(--color-node-document)",
service: "var(--color-node-service)",
table: "var(--color-node-table)",
endpoint: "var(--color-node-endpoint)",
pipeline: "var(--color-node-pipeline)",
schema: "var(--color-node-schema)",
resource: "var(--color-node-resource)",
domain: "var(--color-node-concept)",
flow: "var(--color-node-pipeline)",
step: "var(--color-node-function)",
article: "var(--color-node-article)",
entity: "var(--color-node-entity)",
topic: "var(--color-node-topic)",
claim: "var(--color-node-claim)",
source: "var(--color-node-source)",
};
const typeTextColors: Record<NodeType, string> = {
file: "text-node-file",
function: "text-node-function",
class: "text-node-class",
module: "text-node-module",
concept: "text-node-concept",
config: "text-node-config",
document: "text-node-document",
service: "text-node-service",
table: "text-node-table",
endpoint: "text-node-endpoint",
pipeline: "text-node-pipeline",
schema: "text-node-schema",
resource: "text-node-resource",
domain: "text-node-concept",
flow: "text-node-pipeline",
step: "text-node-function",
article: "text-node-article",
entity: "text-node-entity",
topic: "text-node-topic",
claim: "text-node-claim",
source: "text-node-source",
};
const complexityColors: Record<string, string> = {
simple: "text-node-function",
moderate: "text-accent-dim",
complex: "text-[#c97070]",
};
export interface CustomNodeData extends Record<string, unknown> {
label: string;
nodeType: string;
summary: string;
complexity: string;
isHighlighted: boolean;
searchScore?: number;
isSelected: boolean;
isTourHighlighted: boolean;
isDiffChanged: boolean;
isDiffAffected: boolean;
isDiffFaded: boolean;
isNeighbor: boolean;
isSelectionFaded: boolean;
onNodeClick?: (nodeId: string) => void;
incomingCount?: number;
outgoingCount?: number;
tags?: string[];
}
export type CustomFlowNode = Node<CustomNodeData, "custom">;
function CustomNodeComponent({
id,
data,
}: NodeProps<CustomFlowNode>) {
const knownType = data.nodeType as NodeType;
const barColor = typeColors[knownType] ?? typeColors.file;
const textColor = typeTextColors[knownType] ?? typeTextColors.file;
const complexityColor = complexityColors[data.complexity] ?? complexityColors.simple;
const { t } = useI18n();
if (import.meta.env.DEV && !(knownType in typeColors)) {
console.warn(`[CustomNode] Unknown node type "${data.nodeType}" — using "file" colors`);
}
let extraClass = "";
if (data.isSelected) {
extraClass = "ring-2 ring-accent node-glow";
} else if (data.isTourHighlighted) {
extraClass = "ring-2 ring-accent-dim animate-accent-pulse";
} else if (data.isHighlighted) {
const score = data.searchScore ?? 1;
if (score <= 0.1) {
extraClass = "ring-2 ring-accent-bright";
} else if (score <= 0.3) {
extraClass = "ring-2 ring-accent";
} else {
extraClass = "ring-1 ring-accent-dim/60";
}
}
// Diff overlay styling (composes with above)
if (data.isDiffChanged) {
extraClass += " ring-2 ring-[var(--color-diff-changed)] diff-changed-glow";
} else if (data.isDiffAffected) {
extraClass += " ring-1 ring-[var(--color-diff-affected)] diff-affected-glow";
} else if (data.isDiffFaded) {
extraClass += " diff-faded";
}
// Selection-based dimming (when another node is selected, fade unrelated nodes)
if (data.isSelectionFaded) {
extraClass += " opacity-20 pointer-events-auto";
} else if (data.isNeighbor) {
extraClass += " ring-1 ring-gold-dim/50";
}
const name = data.label ?? "unnamed";
const truncatedName =
name.length > 24 ? name.slice(0, 22) + "..." : name;
return (
<div
className={`relative rounded-lg bg-elevated border border-border-subtle ${extraClass} min-w-[180px] max-w-[220px] overflow-hidden transition-[box-shadow,outline,opacity,filter] duration-200 cursor-pointer shadow-[0_2px_8px_rgba(0,0,0,0.3)]`}
onClick={() => data.onNodeClick?.(id)}
>
{/* Left color bar */}
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
style={{ backgroundColor: barColor }}
/>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="pl-4 pr-3 py-2">
<div className="flex items-center justify-between mb-1">
<span className={`text-[10px] font-semibold uppercase tracking-wider ${textColor}`}>
{data.nodeType}
</span>
<div className="flex items-center gap-1.5">
<span className={`text-[9px] font-mono ${complexityColor}`}>
{data.complexity}
</span>
{data.tags?.includes("tested") && (
<span
className="inline-block w-1.5 h-1.5 rounded-full bg-node-function shadow-[0_0_4px_rgba(90,158,111,0.6)]"
role="img"
aria-label={t.customNode.tested}
title={t.customNode.hasTests}
/>
)}
</div>
</div>
<div className="text-sm font-heading text-text-primary truncate" title={data.label}>
{truncatedName}
</div>
<div className="text-[11px] text-text-secondary mt-1 line-clamp-2 leading-tight">
{data.summary}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
const CustomNode = memo(CustomNodeComponent);
export default CustomNode;

View File

@@ -0,0 +1,66 @@
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
export default function DiffToggle() {
const diffMode = useDashboardStore((s) => s.diffMode);
const toggleDiffMode = useDashboardStore((s) => s.toggleDiffMode);
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
const { t } = useI18n();
const hasDiff = changedNodeIds.size > 0;
return (
<div className="flex items-center gap-2">
<button
onClick={toggleDiffMode}
disabled={!hasDiff}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
diffMode && hasDiff
? "bg-[var(--color-diff-changed-dim)] text-[var(--color-diff-changed)]"
: hasDiff
? "bg-elevated text-text-secondary hover:bg-surface"
: "bg-elevated text-text-muted cursor-not-allowed"
}`}
title={
hasDiff
? diffMode
? t.diffToggle.hideOverlay
: t.diffToggle.showOverlay
: t.diffToggle.noData
}
>
Diff {diffMode && hasDiff ? "ON" : "OFF"}
</button>
{diffMode && hasDiff && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: "var(--color-diff-changed)" }}
/>
<span className="text-text-secondary text-[11px]">
{t.diffToggle.changed}
<span className="text-text-muted ml-0.5">
({changedNodeIds.size})
</span>
</span>
</div>
<div className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: "var(--color-diff-affected)" }}
/>
<span className="text-text-secondary text-[11px]">
{t.diffToggle.affected}
<span className="text-text-muted ml-0.5">
({affectedNodeIds.size})
</span>
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { Node, NodeProps } from "@xyflow/react";
import { useDashboardStore } from "../store";
export interface DomainClusterData extends Record<string, unknown> {
label: string;
summary: string;
entities?: string[];
flowCount: number;
businessRules?: string[];
domainId: string;
}
export type DomainClusterFlowNode = Node<DomainClusterData, "domain-cluster">;
function DomainClusterNode({ data }: NodeProps<DomainClusterFlowNode>) {
const navigateToDomain = useDashboardStore((s) => s.navigateToDomain);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const selectNode = useDashboardStore((s) => s.selectNode);
const isSelected = selectedNodeId === data.domainId;
return (
<div
className={`rounded-xl border-2 px-5 py-4 min-w-[280px] max-w-[360px] cursor-pointer transition-all ${
isSelected
? "border-accent bg-accent/10 shadow-lg shadow-accent/10"
: "border-accent/40 bg-surface hover:border-accent/70"
}`}
onClick={() => selectNode(data.domainId)}
onDoubleClick={() => navigateToDomain(data.domainId)}
>
<Handle type="target" position={Position.Left} className="!bg-accent/60 !w-2 !h-2" />
<Handle type="source" position={Position.Right} className="!bg-accent/60 !w-2 !h-2" />
<div className="font-heading text-sm text-accent font-semibold mb-1 truncate">
{data.label}
</div>
<div className="text-[11px] text-text-secondary line-clamp-2 mb-2">
{data.summary}
</div>
{data.entities && data.entities.length > 0 && (
<div className="mb-2">
<div className="text-[9px] uppercase tracking-wider text-text-muted mb-1">Entities</div>
<div className="flex flex-wrap gap-1">
{data.entities.slice(0, 5).map((e) => (
<span key={e} className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-text-secondary">
{e}
</span>
))}
{data.entities.length > 5 && (
<span className="text-[10px] text-text-muted">+{data.entities.length - 5}</span>
)}
</div>
</div>
)}
<div className="text-[10px] text-text-muted">
{data.flowCount} flow{data.flowCount !== 1 ? "s" : ""}
</div>
</div>
);
}
export default memo(DomainClusterNode);

View File

@@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Controls,
MiniMap,
} from "@xyflow/react";
import type { Edge, Node } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import DomainClusterNode from "./DomainClusterNode";
import type { DomainClusterFlowNode } from "./DomainClusterNode";
import FlowNode from "./FlowNode";
import type { FlowFlowNode } from "./FlowNode";
import StepNode from "./StepNode";
import type { StepFlowNode } from "./StepNode";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
import { mergeElkPositions, nodesToElkInput } from "../utils/layout";
import { applyElkLayout } from "../utils/elk-layout";
import type { KnowledgeGraph, GraphNode } from "@understand-anything/core/types";
const nodeTypes = {
"domain-cluster": DomainClusterNode,
"flow-node": FlowNode,
"step-node": StepNode,
};
function getDomainMeta(node: GraphNode) {
return node.domainMeta;
}
interface BuiltGraph {
nodes: Node[];
edges: Edge[];
dims: Map<string, { width: number; height: number }>;
}
function buildDomainOverview(graph: KnowledgeGraph): BuiltGraph {
const dims = new Map<string, { width: number; height: number }>();
const domainNodes = graph.nodes.filter((n) => n.type === "domain");
// Count flows per domain
const flowCountMap = new Map<string, number>();
for (const edge of graph.edges) {
if (edge.type === "contains_flow") {
flowCountMap.set(edge.source, (flowCountMap.get(edge.source) ?? 0) + 1);
}
}
const rfNodes: DomainClusterFlowNode[] = domainNodes.map((node) => {
const meta = getDomainMeta(node);
const data = {
label: node.name,
summary: node.summary,
entities: meta?.entities as string[] | undefined,
flowCount: flowCountMap.get(node.id) ?? 0,
businessRules: meta?.businessRules as string[] | undefined,
domainId: node.id,
};
dims.set(node.id, { width: 320, height: 180 });
return {
id: node.id,
type: "domain-cluster" as const,
position: { x: 0, y: 0 },
data,
};
});
const rfEdges: Edge[] = graph.edges
.filter((e) => e.type === "cross_domain")
.map((e, i) => ({
id: `cd-${i}-${e.source}-${e.target}`,
source: e.source,
target: e.target,
label: e.description ?? "",
style: { stroke: "var(--color-accent)", strokeDasharray: "6 3", strokeWidth: 2 },
labelStyle: { fill: "var(--color-text-muted)", fontSize: 10 },
labelBgStyle: { fill: "var(--color-surface)", fillOpacity: 0.9 },
labelBgPadding: [6, 4] as [number, number],
labelBgBorderRadius: 4,
animated: true,
}));
return { nodes: rfNodes as unknown as Node[], edges: rfEdges, dims };
}
function buildDomainDetail(
graph: KnowledgeGraph,
domainId: string,
): BuiltGraph {
// Find flows for this domain
const flowIds = new Set(
graph.edges
.filter((e) => e.type === "contains_flow" && e.source === domainId)
.map((e) => e.target),
);
const flowNodes = graph.nodes.filter((n) => flowIds.has(n.id));
const stepEdges = graph.edges.filter(
(e) => e.type === "flow_step" && flowIds.has(e.source),
);
const stepIds = new Set(stepEdges.map((e) => e.target));
const stepNodes = graph.nodes.filter((n) => stepIds.has(n.id));
// Build step order map
const stepOrderMap = new Map<string, number>();
for (const edge of stepEdges) {
stepOrderMap.set(edge.target, edge.weight);
}
// Count steps per flow
const stepCountMap = new Map<string, number>();
for (const edge of stepEdges) {
stepCountMap.set(edge.source, (stepCountMap.get(edge.source) ?? 0) + 1);
}
const dims = new Map<string, { width: number; height: number }>();
const flowRfNodes: FlowFlowNode[] = flowNodes.map((node) => {
const meta = getDomainMeta(node);
dims.set(node.id, { width: 260, height: 120 });
return {
id: node.id,
type: "flow-node" as const,
position: { x: 0, y: 0 },
data: {
label: node.name,
summary: node.summary,
entryPoint: meta?.entryPoint as string | undefined,
entryType: meta?.entryType as string | undefined,
stepCount: stepCountMap.get(node.id) ?? 0,
flowId: node.id,
},
};
});
const stepRfNodes: StepFlowNode[] = stepNodes.map((node) => {
dims.set(node.id, { width: 200, height: 90 });
return {
id: node.id,
type: "step-node" as const,
position: { x: 0, y: 0 },
data: {
label: node.name,
summary: node.summary,
filePath: node.filePath,
stepId: node.id,
order: Math.round((stepOrderMap.get(node.id) ?? 0) * 10),
},
};
});
const rfNodes: Node[] = [...flowRfNodes, ...stepRfNodes];
const rfEdges: Edge[] = stepEdges.map((e, i) => ({
id: `fs-${i}-${e.source}-${e.target}`,
source: e.source,
target: e.target,
style: { stroke: "var(--color-border-medium)", strokeWidth: 1.5 },
animated: false,
}));
return { nodes: rfNodes, edges: rfEdges, dims };
}
function DomainGraphViewInner() {
const domainGraph = useDashboardStore((s) => s.domainGraph);
const activeDomainId = useDashboardStore((s) => s.activeDomainId);
const clearActiveDomain = useDashboardStore((s) => s.clearActiveDomain);
const { t } = useI18n();
// Build structural nodes/edges/dims synchronously; only the layout call
// itself is async, so we memo the structural pieces and run ELK in an
// effect.
const built = useMemo<BuiltGraph | null>(() => {
if (!domainGraph) return null;
if (activeDomainId) {
return buildDomainDetail(domainGraph, activeDomainId);
}
return buildDomainOverview(domainGraph);
}, [domainGraph, activeDomainId]);
const [layout, setLayout] = useState<{ nodes: Node[]; edges: Edge[] }>({
nodes: [],
edges: [],
});
useEffect(() => {
if (!built) {
setLayout({ nodes: [], edges: [] });
return;
}
let cancelled = false;
const { nodes: nodesArray, edges: edgesArray, dims } = built;
// DomainGraphView used dagre LR; preserve that direction with ELK.
const elkInput = nodesToElkInput(nodesArray, edgesArray, dims, {
"elk.direction": "RIGHT",
});
applyElkLayout(elkInput, { strict: import.meta.env.DEV })
.then(({ positioned, issues }) => {
if (cancelled) return;
if (issues.length > 0) {
// Funnel into store so WarningBanner surfaces them.
useDashboardStore.getState().appendLayoutIssues(issues);
}
setLayout({
nodes: mergeElkPositions(nodesArray, positioned),
edges: edgesArray,
});
})
.catch((err) => {
if (cancelled) return;
console.error("[domain ELK] layout failed:", err);
});
return () => {
cancelled = true;
};
}, [built]);
const { nodes, edges } = layout;
// Double-click is handled by individual node components (e.g. DomainClusterNode)
if (!domainGraph) {
return (
<div className="h-full flex items-center justify-center text-text-muted text-sm">
No domain graph available. Run /understand-domain to generate one.
</div>
);
}
return (
<div className="h-full w-full relative">
{activeDomainId && (
<div className="absolute top-3 left-3 z-10">
<button
type="button"
onClick={() => clearActiveDomain()}
className="px-3 py-1.5 text-xs rounded-lg bg-elevated border border-border-subtle text-text-secondary hover:text-text-primary transition-colors"
>
{t.domainView.backToDomains}
</button>
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-border-subtle)"
/>
<Controls />
<MiniMap
nodeColor="var(--color-accent)"
maskColor="var(--glass-bg)"
className="!bg-surface !border !border-border-subtle"
/>
</ReactFlow>
</div>
);
}
export default function DomainGraphView() {
return (
<ReactFlowProvider>
<DomainGraphViewInner />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useRef } from "react";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
import type { KnowledgeGraph } from "@understand-anything/core/types";
import { filterNodes, filterEdges } from "../utils/filters";
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export default function ExportMenu() {
const graph = useDashboardStore((s) => s.graph);
const nodeIdToLayerIds = useDashboardStore((s) => s.nodeIdToLayerIds);
const filters = useDashboardStore((s) => s.filters);
const exportMenuOpen = useDashboardStore((s) => s.exportMenuOpen);
const toggleExportMenu = useDashboardStore((s) => s.toggleExportMenu);
const reactFlowInstance = useDashboardStore((s) => s.reactFlowInstance);
const persona = useDashboardStore((s) => s.persona);
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (exportMenuOpen) {
toggleExportMenu();
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [exportMenuOpen, toggleExportMenu]);
const buildCleanSvg = () => {
if (!reactFlowInstance) return null;
const nodes = reactFlowInstance.getNodes();
const edges = reactFlowInstance.getEdges();
if (nodes.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach((node) => {
const x = node.position.x;
const y = node.position.y;
const width = (node.width ?? 200);
const height = (node.height ?? 80);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
const padding = 40;
const width = maxX - minX + padding * 2;
const height = maxY - minY + padding * 2;
const offsetX = -minX + padding;
const offsetY = -minY + padding;
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
svgContent += `<rect width="100%" height="100%" fill="#0a0a0a"/>`;
edges.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const sx = sourceNode.position.x + (sourceNode.width ?? 200) / 2 + offsetX;
const sy = sourceNode.position.y + (sourceNode.height ?? 80) / 2 + offsetY;
const tx = targetNode.position.x + (targetNode.width ?? 200) / 2 + offsetX;
const ty = targetNode.position.y + (targetNode.height ?? 80) / 2 + offsetY;
svgContent += `<line x1="${sx}" y1="${sy}" x2="${tx}" y2="${ty}" stroke="rgba(212,165,116,0.3)" stroke-width="1.5"/>`;
});
nodes.forEach((node) => {
if (node.type === "group") return;
const x = node.position.x + offsetX;
const y = node.position.y + offsetY;
const w = node.width ?? 200;
const h = node.height ?? 80;
svgContent += `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#1a1a1a" stroke="rgba(212,165,116,0.2)" stroke-width="1"/>`;
svgContent += `<text x="${x + w / 2}" y="${y + h / 2}" fill="#d4a574" text-anchor="middle" dominant-baseline="middle" font-size="12">${escapeXml(String(node.data.label ?? node.id))}</text>`;
});
svgContent += `</svg>`;
return { svgContent, width, height };
};
const exportPNG = async () => {
if (!reactFlowInstance) {
alert("Graph not ready for export");
return;
}
try {
const result = buildCleanSvg();
if (!result) {
alert("No nodes to export");
return;
}
const { svgContent, width, height } = result;
const svgBlob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onerror = () => {
URL.revokeObjectURL(url);
alert("Failed to export PNG: could not render graph as image.");
};
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
alert("Failed to create canvas context");
return;
}
ctx.drawImage(img, 0, 0, width * 2, height * 2);
URL.revokeObjectURL(url);
const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`;
canvas.toBlob((blob) => {
if (blob) {
downloadBlob(blob, filename);
toggleExportMenu();
} else {
alert("Failed to export PNG: image encoding failed.");
}
}, "image/png");
};
img.src = url;
} catch (error) {
console.error("PNG export failed:", error);
alert(`Failed to export PNG: ${error instanceof Error ? error.message : String(error)}`);
}
};
const exportSVG = () => {
if (!reactFlowInstance) {
alert("Graph not ready for export");
return;
}
try {
const result = buildCleanSvg();
if (!result) {
alert("No nodes to export");
return;
}
const blob = new Blob([result.svgContent], { type: "image/svg+xml;charset=utf-8" });
const filename = `${graph?.project.name ?? "knowledge-graph"}-export.svg`;
downloadBlob(blob, filename);
toggleExportMenu();
} catch (error) {
console.error("SVG export failed:", error);
alert(`Failed to export SVG: ${error instanceof Error ? error.message : String(error)}`);
}
};
const exportJSON = () => {
if (!graph) {
alert("No graph loaded");
return;
}
try {
// Apply persona and filters to create filtered graph
// Non-technical persona: hide function/class sub-nodes, keep everything else
const subFileTypes = new Set(["function", "class"]);
let filteredGraphNodes = persona === "non-technical"
? graph.nodes.filter((n) => !subFileTypes.has(n.type))
: graph.nodes;
filteredGraphNodes = filterNodes(filteredGraphNodes, nodeIdToLayerIds, filters);
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
let filteredGraphEdges = graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target)
);
filteredGraphEdges = filterEdges(filteredGraphEdges, filteredNodeIds, filters);
const filteredGraph: KnowledgeGraph = {
...graph,
nodes: filteredGraphNodes,
edges: filteredGraphEdges,
};
const json = JSON.stringify(filteredGraph, null, 2);
const blob = new Blob([json], { type: "application/json" });
const filename = `${graph.project.name ?? "knowledge-graph"}-export.json`;
downloadBlob(blob, filename);
toggleExportMenu();
} catch (error) {
console.error("JSON export failed:", error);
alert(`Failed to export JSON: ${error instanceof Error ? error.message : String(error)}`);
}
};
return (
<div ref={containerRef} className="relative">
<button
onClick={toggleExportMenu}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
title={t.export.title}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{t.export.label}
</button>
{exportMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-52 glass rounded-lg shadow-xl overflow-hidden animate-fade-slide-in z-50">
<div className="p-2">
<button
onClick={exportPNG}
disabled={!reactFlowInstance}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-text-primary hover:bg-elevated transition-colors rounded-lg text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{t.export.asPNG}</span>
</button>
<button
onClick={exportSVG}
disabled={!reactFlowInstance}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-text-primary hover:bg-elevated transition-colors rounded-lg text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
<span>{t.export.asSVG}</span>
</button>
<button
onClick={exportJSON}
disabled={!graph}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-text-primary hover:bg-elevated transition-colors rounded-lg text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span>{t.export.asJSON}</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { useMemo, useState } from "react";
import type { GraphNode } from "@understand-anything/core/types";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
interface FileEntry {
name: string;
path: string;
type: "folder" | "file";
children: FileEntry[];
nodeId?: string;
}
function normalizeFilePath(filePath: string): string | null {
const normalized = filePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
if (!normalized || normalized === "." || normalized.includes("\0")) return null;
if (normalized.split("/").some((part) => part === "..")) return null;
return normalized;
}
function bestFileNode(existing: GraphNode | undefined, candidate: GraphNode): GraphNode {
if (!existing) return candidate;
if (existing.type !== "file" && candidate.type === "file") return candidate;
return existing;
}
function buildFileTree(nodes: GraphNode[], activeLayerNodeIds?: Set<string>): FileEntry[] {
const files = new Map<string, GraphNode>();
for (const node of nodes) {
if (activeLayerNodeIds && !activeLayerNodeIds.has(node.id)) continue;
if (!node.filePath) continue;
const filePath = normalizeFilePath(node.filePath);
if (!filePath) continue;
files.set(filePath, bestFileNode(files.get(filePath), node));
}
const root: FileEntry = { name: "", path: "", type: "folder", children: [] };
const folders = new Map<string, FileEntry>([["", root]]);
for (const [filePath, node] of files) {
const parts = filePath.split("/");
let parent = root;
let currentPath = "";
for (let i = 0; i < parts.length; i += 1) {
const name = parts[i];
currentPath = currentPath ? `${currentPath}/${name}` : name;
const isFile = i === parts.length - 1;
if (isFile) {
parent.children.push({
name,
path: currentPath,
type: "file",
children: [],
nodeId: node.id,
});
continue;
}
let folder = folders.get(currentPath);
if (!folder) {
folder = { name, path: currentPath, type: "folder", children: [] };
folders.set(currentPath, folder);
parent.children.push(folder);
}
parent = folder;
}
}
const sortEntries = (entries: FileEntry[]): FileEntry[] =>
entries
.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
})
.map((entry) => ({
...entry,
children: sortEntries(entry.children),
}));
return sortEntries(root.children);
}
function FileTreeRow({
entry,
depth,
expanded,
toggleFolder,
openFile,
}: {
entry: FileEntry;
depth: number;
expanded: Set<string>;
toggleFolder: (path: string) => void;
openFile: (nodeId: string) => void;
}) {
const isExpanded = expanded.has(entry.path);
const paddingLeft = 12 + depth * 14;
if (entry.type === "folder") {
return (
<>
<button
type="button"
onClick={() => toggleFolder(entry.path)}
className="w-full flex items-center gap-1.5 py-1.5 pr-3 text-left text-xs text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors"
style={{ paddingLeft }}
title={entry.path}
>
<span className="w-3 text-text-muted">{isExpanded ? "v" : ">"}</span>
<span className="truncate font-medium">{entry.name}</span>
</button>
{isExpanded &&
entry.children.map((child) => (
<FileTreeRow
key={child.path}
entry={child}
depth={depth + 1}
expanded={expanded}
toggleFolder={toggleFolder}
openFile={openFile}
/>
))}
</>
);
}
return (
<button
type="button"
onClick={() => entry.nodeId && openFile(entry.nodeId)}
className="w-full flex items-center gap-1.5 py-1.5 pr-3 text-left text-xs text-text-secondary hover:text-accent hover:bg-accent/5 transition-colors"
style={{ paddingLeft }}
title={entry.path}
>
<span className="w-3 text-text-muted">-</span>
<span className="truncate font-mono">{entry.name}</span>
</button>
);
}
export default function FileExplorer() {
const graph = useDashboardStore((s) => s.graph);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
const navigateToNode = useDashboardStore((s) => s.navigateToNode);
const { t } = useI18n();
const activeLayer = graph?.layers.find((layer) => layer.id === activeLayerId);
const activeLayerNodeIds = useMemo(
() => navigationLevel === "layer-detail" && activeLayer ? new Set(activeLayer.nodeIds) : undefined,
[navigationLevel, activeLayer],
);
const entries = useMemo(() => buildFileTree(graph?.nodes ?? [], activeLayerNodeIds), [graph, activeLayerNodeIds]);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
// Navigate the graph first (drills into layer + selects node, which clears the
// code viewer), then re-open the viewer so the source panel stays visible.
const handleOpenFile = (nodeId: string) => {
navigateToNode(nodeId);
openCodeViewer(nodeId);
};
const toggleFolder = (folderPath: string) => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(folderPath)) {
next.delete(folderPath);
} else {
next.add(folderPath);
}
return next;
});
};
const totalFiles = useMemo(() => {
const countFiles = (items: FileEntry[]): number =>
items.reduce(
(count, item) => count + (item.type === "file" ? 1 : countFiles(item.children)),
0,
);
return countFiles(entries);
}, [entries]);
if (!graph) {
return (
<div className="h-full flex items-center justify-center p-5 text-sm text-text-muted">
{t.common.noGraphLoaded}
</div>
);
}
return (
<div className="h-full flex flex-col min-h-0">
<div className="px-4 py-3 border-b border-border-subtle shrink-0">
<div className="text-[11px] font-semibold uppercase tracking-wider text-accent">
{activeLayer ? activeLayer.name : t.fileExplorer.analyzedFiles}
</div>
<div className="text-xs text-text-muted mt-1">
{totalFiles} {activeLayer ? "个文档" : t.fileExplorer.filesFromGraph}
</div>
</div>
<div className="flex-1 overflow-auto py-2">
{entries.length === 0 ? (
<div className="px-4 py-6 text-sm text-text-muted">{t.fileExplorer.noFilePathsFound}</div>
) : (
entries.map((entry) => (
<FileTreeRow
key={entry.path}
entry={entry}
depth={0}
expanded={expanded}
toggleFolder={toggleFolder}
openFile={handleOpenFile}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useRef } from "react";
import { useDashboardStore, ALL_NODE_TYPES, ALL_COMPLEXITIES, ALL_EDGE_CATEGORIES } from "../store";
import type { NodeType, Complexity, EdgeCategory } from "../store";
import { useI18n } from "../contexts/I18nContext";
export default function FilterPanel() {
const graph = useDashboardStore((s) => s.graph);
const filters = useDashboardStore((s) => s.filters);
const setFilters = useDashboardStore((s) => s.setFilters);
const resetFilters = useDashboardStore((s) => s.resetFilters);
const hasActiveFilters = useDashboardStore((s) => s.hasActiveFilters);
const filterPanelOpen = useDashboardStore((s) => s.filterPanelOpen);
const toggleFilterPanel = useDashboardStore((s) => s.toggleFilterPanel);
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const allNodeTypes = ALL_NODE_TYPES;
const allComplexities = ALL_COMPLEXITIES;
const allEdgeCategories = ALL_EDGE_CATEGORIES;
const layers = graph?.layers ?? [];
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (filterPanelOpen) {
toggleFilterPanel();
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [filterPanelOpen, toggleFilterPanel]);
const toggleNodeType = (type: NodeType) => {
const newTypes = new Set(filters.nodeTypes);
if (newTypes.has(type)) {
newTypes.delete(type);
} else {
newTypes.add(type);
}
setFilters({ nodeTypes: newTypes });
};
const toggleComplexity = (complexity: Complexity) => {
const newComplexities = new Set(filters.complexities);
if (newComplexities.has(complexity)) {
newComplexities.delete(complexity);
} else {
newComplexities.add(complexity);
}
setFilters({ complexities: newComplexities });
};
const toggleLayer = (layerId: string) => {
const newLayers = new Set(filters.layerIds);
if (newLayers.has(layerId)) {
newLayers.delete(layerId);
} else {
newLayers.add(layerId);
}
setFilters({ layerIds: newLayers });
};
const toggleEdgeCategory = (category: EdgeCategory) => {
const newCategories = new Set(filters.edgeCategories);
if (newCategories.has(category)) {
newCategories.delete(category);
} else {
newCategories.add(category);
}
setFilters({ edgeCategories: newCategories });
};
const isActive = hasActiveFilters();
return (
<div ref={containerRef} className="relative">
<button
onClick={toggleFilterPanel}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
isActive
? "bg-gold/20 text-gold hover:bg-gold/30"
: "bg-elevated text-text-secondary hover:text-text-primary"
}`}
title="Filter graph (F)"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
{t.common.filter}
</button>
{filterPanelOpen && (
<div className="absolute right-0 top-full mt-2 w-72 glass rounded-lg shadow-xl overflow-hidden animate-fade-slide-in z-50">
<div className="p-4 space-y-4">
{/* Node Types */}
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
{t.filterPanel.nodeTypes}
</h3>
<div className="space-y-1.5">
{allNodeTypes.map((type) => (
<label
key={type}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.nodeTypes.has(type)}
onChange={() => toggleNodeType(type)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">{type}</span>
</label>
))}
</div>
</div>
{/* Complexity */}
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
{t.filterPanel.complexity}
</h3>
<div className="space-y-1.5">
{allComplexities.map((complexity) => (
<label
key={complexity}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.complexities.has(complexity)}
onChange={() => toggleComplexity(complexity)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">{complexity}</span>
</label>
))}
</div>
</div>
{/* Layers */}
{layers.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
{t.filterPanel.layers}
</h3>
<div className="space-y-1.5">
{layers.map((layer) => (
<label
key={layer.id}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.layerIds.has(layer.id)}
onChange={() => toggleLayer(layer.id)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<div className="w-2 h-2 rounded-full bg-gold/50 shrink-0" />
<span className="text-sm text-text-primary">{layer.name}</span>
</label>
))}
</div>
</div>
)}
{/* Edge Categories */}
<div>
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
{t.filterPanel.edgeCategories}
</h3>
<div className="space-y-1.5">
{allEdgeCategories.map((category) => (
<label
key={category}
className="flex items-center gap-2 cursor-pointer hover:bg-elevated/50 rounded px-2 py-1 transition-colors"
>
<input
type="checkbox"
checked={filters.edgeCategories.has(category)}
onChange={() => toggleEdgeCategory(category)}
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">
{category.replace(/-/g, " ")}
</span>
</label>
))}
</div>
</div>
{/* Reset Button */}
{isActive && (
<button
onClick={resetFilters}
className="w-full px-3 py-1.5 text-sm bg-elevated hover:bg-gold/20 text-text-secondary hover:text-gold rounded-lg transition-colors"
>
{t.common.resetAll}
</button>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { Node, NodeProps } from "@xyflow/react";
import { useDashboardStore } from "../store";
export interface FlowNodeData extends Record<string, unknown> {
label: string;
summary: string;
entryPoint?: string;
entryType?: string;
stepCount: number;
flowId: string;
}
export type FlowFlowNode = Node<FlowNodeData, "flow-node">;
function FlowNode({ data }: NodeProps<FlowFlowNode>) {
const selectNode = useDashboardStore((s) => s.selectNode);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const isSelected = selectedNodeId === data.flowId;
return (
<div
className={`rounded-lg border px-4 py-3 min-w-[240px] max-w-[320px] cursor-pointer transition-all ${
isSelected
? "border-accent bg-accent/10"
: "border-border-medium bg-surface hover:border-accent/50"
}`}
onClick={() => selectNode(data.flowId)}
>
<Handle type="target" position={Position.Left} className="!bg-accent/60 !w-2 !h-2" />
<Handle type="source" position={Position.Right} className="!bg-accent/60 !w-2 !h-2" />
{data.entryPoint && (
<div className="text-[9px] font-mono text-accent/70 mb-1 truncate">
{data.entryPoint}
</div>
)}
<div className="text-xs font-semibold text-text-primary mb-1 truncate">
{data.label}
</div>
<div className="text-[10px] text-text-secondary line-clamp-2">
{data.summary}
</div>
<div className="text-[9px] text-text-muted mt-1">
{data.stepCount} step{data.stepCount !== 1 ? "s" : ""}
</div>
</div>
);
}
export default memo(FlowNode);

View File

@@ -0,0 +1,105 @@
import type { KeyboardShortcut } from "../hooks/useKeyboardShortcuts";
import { formatShortcutKey } from "../hooks/useKeyboardShortcuts";
import { useI18n } from "../contexts/I18nContext";
interface KeyboardShortcutsHelpProps {
shortcuts: KeyboardShortcut[];
onClose: () => void;
}
export default function KeyboardShortcutsHelp({
shortcuts,
onClose,
}: KeyboardShortcutsHelpProps) {
const { t } = useI18n();
// Group shortcuts by category
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
if (!acc[shortcut.category]) {
acc[shortcut.category] = [];
}
acc[shortcut.category].push(shortcut);
return acc;
}, {} as Record<string, KeyboardShortcut[]>);
// Translate category names
const categoryTranslations: Record<string, string> = {
"General": t.keyboardShortcuts.general,
"Navigation": t.keyboardShortcuts.navigation,
"Tour": t.keyboardShortcuts.tour,
"View": t.keyboardShortcuts.view,
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="glass rounded-lg shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-auto m-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 glass-heavy border-b border-border-subtle px-6 py-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-heading text-text-primary">
{t.keyboardShortcuts.title}
</h2>
<p className="text-xs text-text-muted mt-1">
{t.keyboardShortcuts.toggleHint}
</p>
</div>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Shortcuts list */}
<div className="p-6 space-y-6">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-accent uppercase tracking-wider mb-3">
{categoryTranslations[category] ?? category}
</h3>
<div className="space-y-2">
{categoryShortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between py-2 px-3 rounded hover:bg-elevated transition-colors"
>
<span className="text-sm text-text-secondary">
{shortcut.description}
</span>
<kbd className="kbd">{formatShortcutKey(shortcut)}</kbd>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="sticky bottom-0 glass-heavy border-t border-border-subtle px-6 py-3 text-center">
<p className="text-xs text-text-muted">
{t.keyboardShortcuts.closeHint}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { useMemo, useCallback } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Controls,
MiniMap,
} from "@xyflow/react";
import type { Edge, Node } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import CustomNode from "./CustomNode";
import type { CustomNodeData } from "./CustomNode";
import { useDashboardStore } from "../store";
import { applyForceLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
import type { KnowledgeGraph } from "@understand-anything/core/types";
const nodeTypes = {
custom: CustomNode,
};
/** Edge style presets by knowledge edge type. */
const EDGE_STYLES: Record<string, React.CSSProperties> = {
related: { stroke: "var(--color-border-medium)", strokeWidth: 0.5, opacity: 0.12 },
cites: { stroke: "var(--color-node-source)", strokeWidth: 1.5, strokeDasharray: "6 3" },
contradicts: { stroke: "#c97070", strokeWidth: 2 },
builds_on: { stroke: "var(--color-node-claim)", strokeWidth: 1.5 },
exemplifies: { stroke: "var(--color-node-entity)", strokeWidth: 1, strokeDasharray: "3 3" },
categorized_under: { stroke: "var(--color-border-medium)", strokeWidth: 0.5, opacity: 0.08 },
authored_by: { stroke: "var(--color-node-entity)", strokeWidth: 1, strokeDasharray: "4 4" },
implements: { stroke: "var(--color-node-function)", strokeWidth: 1, opacity: 0.4 },
depends_on: { stroke: "var(--color-node-module)", strokeWidth: 1, opacity: 0.4 },
};
/** Compute node size based on connection count. */
function getNodeDimensions(
edgeCount: number,
): { width: number; height: number } {
const scale = Math.min(1.5, Math.max(0.85, 0.85 + edgeCount * 0.03));
return {
width: Math.round(NODE_WIDTH * scale),
height: Math.round(NODE_HEIGHT * scale),
};
}
/**
* Compute the stable layout (positions) from graph topology.
* This only re-runs when the graph data or filters change, NOT on selection/search.
*/
function computeLayout(
graph: KnowledgeGraph,
): { positionMap: Map<string, { x: number; y: number }>; edgeCounts: Map<string, number>; communityMap: Map<string, number> } {
const edgeCounts = new Map<string, number>();
for (const edge of graph.edges) {
edgeCounts.set(edge.source, (edgeCounts.get(edge.source) ?? 0) + 1);
edgeCounts.set(edge.target, (edgeCounts.get(edge.target) ?? 0) + 1);
}
const communityMap = new Map<string, number>();
graph.layers.forEach((layer, i) => {
for (const nodeId of layer.nodeIds) {
communityMap.set(nodeId, i);
}
});
const dims = new Map<string, { width: number; height: number }>();
for (const node of graph.nodes) {
dims.set(node.id, getNodeDimensions(edgeCounts.get(node.id) ?? 0));
}
// Build temporary nodes/edges for layout computation only
const tmpNodes: Node[] = graph.nodes.map((node) => ({
id: node.id,
type: "custom" as const,
position: { x: 0, y: 0 },
data: {},
}));
const tmpEdges: Edge[] = graph.edges.map((e, i) => ({
id: `ke-${i}`,
source: e.source,
target: e.target,
}));
const { nodes: layoutedNodes } = applyForceLayout(tmpNodes, tmpEdges, dims, communityMap);
const positionMap = new Map<string, { x: number; y: number }>();
for (const n of layoutedNodes) {
positionMap.set(n.id, n.position);
}
return { positionMap, edgeCounts, communityMap };
}
function KnowledgeGraphViewInner() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const selectNode = useDashboardStore((s) => s.selectNode);
const searchResultsRaw = useDashboardStore((s) => s.searchResults);
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const onNodeClick = useCallback(
(nodeId: string) => selectNode(nodeId),
[selectNode],
);
const searchResults = useMemo(
() => new Map(searchResultsRaw.map((r) => [r.nodeId, r.score])),
[searchResultsRaw],
);
const tourSet = useMemo(
() => new Set(tourHighlightedNodeIds),
[tourHighlightedNodeIds],
);
// Filter graph — only recompute when graph data or filters change
const filteredGraph = useMemo((): KnowledgeGraph | null => {
if (!graph) return null;
const filteredNodes = graph.nodes.filter((n) => {
if (["article", "entity", "topic", "claim", "source"].includes(n.type)) {
return nodeTypeFilters.knowledge !== false;
}
return true;
});
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
const filteredEdges = graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
return { ...graph, nodes: filteredNodes, edges: filteredEdges };
}, [graph, nodeTypeFilters]);
// Compute layout ONCE per graph/filter change — stable positions
const { positionMap, edgeCounts } = useMemo(() => {
if (!filteredGraph) return { positionMap: new Map(), edgeCounts: new Map() };
return computeLayout(filteredGraph);
}, [filteredGraph]);
// Build visual nodes/edges — recomputes on selection/search/tour WITHOUT re-layout
const { nodes, edges } = useMemo(() => {
if (!filteredGraph) return { nodes: [], edges: [] };
const neighborIds = new Set<string>();
if (focusNodeId || selectedNodeId) {
const focusId = focusNodeId ?? selectedNodeId;
for (const edge of filteredGraph.edges) {
if (edge.source === focusId) neighborIds.add(edge.target);
if (edge.target === focusId) neighborIds.add(edge.source);
}
}
const rfNodes: Node[] = filteredGraph.nodes.map((node) => {
const isSelected = node.id === selectedNodeId;
const isFocused = node.id === focusNodeId;
const isNeighbor = neighborIds.has(node.id);
const isSelectionFaded =
(focusNodeId || selectedNodeId) &&
!isSelected &&
!isFocused &&
!isNeighbor;
const searchScore = searchResults.get(node.id);
const isHighlighted = searchScore !== undefined;
const isTourHighlighted = tourSet.has(node.id);
const data: CustomNodeData = {
label: node.name,
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted,
searchScore,
isSelected,
isTourHighlighted,
isDiffChanged: false,
isDiffAffected: false,
isDiffFaded: false,
isNeighbor,
isSelectionFaded: !!isSelectionFaded,
onNodeClick,
incomingCount: edgeCounts.get(node.id) ?? 0,
tags: node.tags,
};
return {
id: node.id,
type: "custom" as const,
position: positionMap.get(node.id) ?? { x: 0, y: 0 },
data,
};
});
const activeId = focusNodeId ?? selectedNodeId;
const rfEdges: Edge[] = filteredGraph.edges.map((e) => {
const baseStyle = EDGE_STYLES[e.type] ?? EDGE_STYLES.related;
const isConnected = activeId && (e.source === activeId || e.target === activeId);
// When a node is selected: highlight connected edges, dim the rest
let style: React.CSSProperties;
if (activeId) {
if (isConnected) {
style = {
...baseStyle,
strokeWidth: Math.max(2, (baseStyle.strokeWidth as number ?? 1) * 1.5),
opacity: 1,
};
} else {
style = { ...baseStyle, opacity: 0.04 };
}
} else {
style = baseStyle;
}
return {
id: `ke-${e.source}-${e.target}-${e.type}`,
source: e.source,
target: e.target,
style,
animated: e.type === "contradicts" && (!activeId || !!isConnected),
label: isConnected && e.type !== "related" && e.type !== "categorized_under"
? e.type.replace(/_/g, " ")
: undefined,
labelStyle: { fill: "var(--color-text-muted)", fontSize: 9, opacity: 0.7 },
labelBgStyle: { fill: "var(--color-surface)", fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 3,
};
});
return { nodes: rfNodes, edges: rfEdges };
}, [filteredGraph, selectedNodeId, focusNodeId, searchResults, tourSet, onNodeClick, positionMap, edgeCounts]);
if (!graph) {
return (
<div className="h-full flex items-center justify-center text-text-muted text-sm">
No knowledge graph available. Run /understand-knowledge to generate one.
</div>
);
}
return (
<div className="h-full w-full relative">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.05}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-border-subtle)"
/>
<Controls />
<MiniMap
nodeColor={(n) => {
const data = n.data as CustomNodeData | undefined;
const type = data?.nodeType ?? "article";
const colorMap: Record<string, string> = {
article: "var(--color-node-article)",
entity: "var(--color-node-entity)",
topic: "var(--color-node-topic)",
claim: "var(--color-node-claim)",
source: "var(--color-node-source)",
};
return colorMap[type] ?? "var(--color-accent)";
}}
maskColor="var(--glass-bg)"
className="!bg-surface !border !border-border-subtle"
/>
</ReactFlow>
</div>
);
}
export default function KnowledgeGraphView() {
return (
<ReactFlowProvider>
<KnowledgeGraphViewInner />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,104 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
const complexityColors: Record<string, string> = {
simple: "text-node-function",
moderate: "text-gold-dim",
complex: "text-[#c97070]",
};
export interface LayerClusterData extends Record<string, unknown> {
layerId: string;
layerName: string;
layerDescription: string;
fileCount: number;
aggregateComplexity: string;
layerColorIndex: number;
searchMatchCount?: number;
onDrillIn: (layerId: string) => void;
}
export type LayerClusterFlowNode = Node<LayerClusterData, "layer-cluster">;
function LayerClusterNode({
data,
}: NodeProps<LayerClusterFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
const complexityColor =
complexityColors[data.aggregateComplexity] ?? complexityColors.simple;
return (
<div
className="relative rounded-xl bg-elevated border border-border-subtle overflow-hidden cursor-pointer transition-all duration-200 hover:border-gold/40 hover:shadow-lg group"
style={{
width: 300,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
onClick={() => data.onDrillIn(data.layerId)}
>
{/* Left color bar */}
<div
className="absolute left-0 top-0 bottom-0 w-1.5 rounded-l-xl"
style={{ backgroundColor: color.label }}
/>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="pl-5 pr-4 py-4">
{/* Header row */}
<div className="flex items-center justify-between mb-2">
<span
className="text-[10px] font-semibold uppercase tracking-wider"
style={{ color: color.label }}
>
</span>
<div className="flex items-center gap-2">
{data.searchMatchCount != null && data.searchMatchCount > 0 && (
<span className="text-[10px] font-mono bg-gold/20 text-gold px-1.5 py-0.5 rounded">
{data.searchMatchCount} match{data.searchMatchCount !== 1 ? "es" : ""}
</span>
)}
<span className={`text-[10px] font-mono ${complexityColor}`}>
{data.aggregateComplexity}
</span>
</div>
</div>
{/* Layer name */}
<div className="text-lg font-heading text-text-primary mb-1">
{data.layerName}
</div>
{/* Description */}
<div className="text-[11px] text-text-secondary line-clamp-2 leading-tight mb-3">
{data.layerDescription}
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<span className="text-[11px] text-text-muted">
{data.fileCount}
</span>
<span className="text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
</span>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
export default memo(LayerClusterNode);

View File

@@ -0,0 +1,72 @@
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
// Shared layer color palette — used by LayerLegend, LayerClusterNode, PortalNode, and GraphView
export const LAYER_PALETTE = [
{ bg: "rgba(74, 124, 155, 0.12)", border: "rgba(74, 124, 155, 0.4)", label: "#4a7c9b" }, // blue (API)
{ bg: "rgba(90, 158, 111, 0.12)", border: "rgba(90, 158, 111, 0.4)", label: "#5a9e6f" }, // green (Data)
{ bg: "rgba(139, 111, 176, 0.12)", border: "rgba(139, 111, 176, 0.4)", label: "#8b6fb0" }, // purple (Service)
{ bg: "rgba(201, 160, 108, 0.12)", border: "rgba(201, 160, 108, 0.4)", label: "#c9a06c" }, // gold (Config)
{ bg: "rgba(176, 122, 138, 0.12)", border: "rgba(176, 122, 138, 0.4)", label: "#b07a8a" }, // pink (UI)
{ bg: "rgba(74, 155, 140, 0.12)", border: "rgba(74, 155, 140, 0.4)", label: "#4a9b8c" }, // teal (Middleware)
{ bg: "rgba(120, 130, 145, 0.12)", border: "rgba(120, 130, 145, 0.4)", label: "#788291" }, // slate (Test)
];
export function getLayerColor(index: number) {
return LAYER_PALETTE[index % LAYER_PALETTE.length];
}
export default function LayerLegend() {
const graph = useDashboardStore((s) => s.graph);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const { t } = useI18n();
const layers = graph?.layers ?? [];
const hasLayers = layers.length > 0;
if (!hasLayers) return null;
const activeLayer = layers.find((l) => l.id === activeLayerId);
return (
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-text-secondary whitespace-nowrap">
{navigationLevel === "overview"
? `${layers.length} ${t.layer.label}`
: activeLayer?.name ?? t.layer.defaultName}
</span>
<div className="flex items-center gap-3">
{layers.map((layer, i) => {
const color = getLayerColor(i);
const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId;
return (
<div key={layer.id} className="flex items-center gap-1 whitespace-nowrap">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
backgroundColor: color.label,
opacity: navigationLevel === "layer-detail" && !isActive ? 0.3 : 1,
}}
/>
<span
className={`text-[11px] ${
isActive ? "text-text-primary font-medium" : "text-text-secondary"
}`}
style={{
opacity: navigationLevel === "layer-detail" && !isActive ? 0.4 : 1,
}}
>
{layer.name}
<span className="text-text-muted ml-0.5">
({layer.nodeIds.length})
</span>
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
export default function LearnPanel() {
const graph = useDashboardStore((s) => s.graph);
const tourActive = useDashboardStore((s) => s.tourActive);
const currentTourStep = useDashboardStore((s) => s.currentTourStep);
const startTour = useDashboardStore((s) => s.startTour);
const stopTour = useDashboardStore((s) => s.stopTour);
const setTourStep = useDashboardStore((s) => s.setTourStep);
const nextTourStep = useDashboardStore((s) => s.nextTourStep);
const prevTourStep = useDashboardStore((s) => s.prevTourStep);
const selectNode = useDashboardStore((s) => s.selectNode);
const { t } = useI18n();
const tourSteps = useMemo(
() => graph?.tour ? [...graph.tour].sort((a, b) => a.order - b.order) : [],
[graph?.tour]
);
const hasTour = tourSteps.length > 0;
// State 1: No tour available
if (!hasTour) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-center px-4">
<div className="text-2xl mb-2 text-text-muted">&#x1f9ed;</div>
<p className="text-text-muted text-sm">{t.learnPanel.noTour}</p>
<p className="text-text-muted text-xs mt-1">
{t.learnPanel.noTourHint}
</p>
</div>
</div>
);
}
// State 2: Tour available but not started
if (!tourActive) {
return (
<div className="h-full w-full overflow-auto p-5">
<div className="mb-4">
<h2 className="text-lg font-heading text-text-primary mb-1">{t.learnPanel.projectTour}</h2>
<p className="text-xs text-text-muted">
{tourSteps.length} {t.learnPanel.steps} &middot; {t.learnPanel.guidedWalkthrough}
</p>
</div>
<button
onClick={startTour}
className="w-full mb-4 bg-accent/10 border border-accent/30 text-accent text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-accent/20 transition-colors"
>
{t.learnPanel.startTour}
</button>
<div className="space-y-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
{t.learnPanel.steps}
</h3>
{tourSteps.map((step, i) => (
<div
key={step.order}
className="flex items-start gap-2 text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle"
>
<span className="text-accent font-mono shrink-0 mt-0.5">
{i + 1}.
</span>
<span className="text-text-secondary">{step.title}</span>
</div>
))}
</div>
</div>
);
}
// State 3: Tour active
const step = tourSteps[currentTourStep];
if (!step) return null;
const totalSteps = tourSteps.length;
const progressPct = ((currentTourStep + 1) / totalSteps) * 100;
const isFirst = currentTourStep === 0;
const isLast = currentTourStep === totalSteps - 1;
return (
<div className="h-full w-full flex flex-col overflow-hidden">
{/* Header with progress counter and exit */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider">
{t.learnPanel.tour}
</h3>
<span className="text-xs text-text-muted">
{currentTourStep + 1} / {totalSteps}
</span>
</div>
<button
onClick={stopTour}
className="text-[10px] text-text-muted hover:text-text-secondary transition-colors"
>
{t.learnPanel.exitTour}
</button>
</div>
{/* Progress bar */}
<div className="h-1 bg-elevated shrink-0">
<div
className="h-full bg-accent transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{/* Step title */}
<h2 className="text-lg font-heading text-text-primary mb-3">{step.title}</h2>
{/* Description via ReactMarkdown */}
<div className="text-sm text-text-secondary leading-relaxed mb-4 tour-markdown">
<ReactMarkdown
components={{
p: ({ children }) => (
<p className="mb-1.5 last:mb-0">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-text-primary">{children}</strong>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<code className="block bg-elevated rounded px-2 py-1.5 mb-1.5 overflow-x-auto text-[11px] leading-relaxed">
{children}
</code>
) : (
<code className="bg-elevated rounded px-1 py-0.5 text-[11px]">
{children}
</code>
);
},
ul: ({ children }) => (
<ul className="list-disc list-inside mb-1.5 space-y-0.5">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside mb-1.5 space-y-0.5">
{children}
</ol>
),
}}
>
{step.description}
</ReactMarkdown>
</div>
{/* Language lesson */}
{step.languageLesson && (
<div className="bg-accent/5 border border-accent/20 rounded p-3 mb-4">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-1.5">
Language Lesson
</h4>
<p className="text-sm text-text-secondary leading-relaxed">
{step.languageLesson}
</p>
</div>
)}
{/* Referenced component pills */}
{step.nodeIds.length > 0 && (
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Referenced Components
</h4>
<div className="flex flex-wrap gap-1.5">
{step.nodeIds.map((nodeId) => {
const node = graph?.nodes.find((n) => n.id === nodeId);
return (
<button
key={nodeId}
onClick={() => selectNode(nodeId)}
className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full hover:text-text-primary transition-colors cursor-pointer"
>
{node?.name ?? nodeId}
</button>
);
})}
</div>
</div>
)}
</div>
{/* Navigation: dots + prev/next */}
<div className="px-3 py-2 border-t border-border-subtle shrink-0">
{/* Step dots */}
<div className="flex justify-center gap-1.5 mb-2">
{tourSteps.map((_, i) => (
<button
key={i}
onClick={() => setTourStep(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentTourStep
? "bg-accent"
: "bg-elevated hover:bg-surface"
}`}
aria-label={`Go to step ${i + 1}`}
/>
))}
</div>
{/* Prev / Next buttons */}
<div className="flex gap-2">
<button
onClick={prevTourStep}
disabled={isFirst}
className="flex-1 text-xs bg-elevated text-text-secondary py-1.5 rounded-lg hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t.learnPanel.prev}
</button>
<button
onClick={isLast ? stopTour : nextTourStep}
className="flex-1 text-xs bg-accent/10 border border-accent/30 text-accent py-1.5 rounded-lg hover:bg-accent/20 transition-colors"
>
{isLast ? t.learnPanel.finish : t.learnPanel.next}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { useI18n } from "../contexts/I18nContext";
export type MobileTab = "graph" | "info" | "files";
interface Props {
activeTab: MobileTab;
onTabChange: (tab: MobileTab) => void;
}
const tabIcons: Record<MobileTab, ReactNode> = {
graph: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
<circle cx="6" cy="7" r="2" />
<circle cx="18" cy="7" r="2" />
<circle cx="12" cy="17" r="2" />
<path strokeLinecap="round" d="M7.6 8.5L11 15.5M16.4 8.5L13 15.5M8 7h8" />
</svg>
),
info: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
<circle cx="12" cy="12" r="9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 11v5M12 8h.01" />
</svg>
),
files: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6.5A1.5 1.5 0 0 1 5.5 5h3.382a1.5 1.5 0 0 1 1.342.83l.671 1.34A1.5 1.5 0 0 0 12.236 8H18.5A1.5 1.5 0 0 1 20 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z"
/>
</svg>
),
};
const tabOrder: MobileTab[] = ["graph", "info", "files"];
export default function MobileBottomNav({ activeTab, onTabChange }: Props) {
const { t } = useI18n();
const labels: Record<MobileTab, string> = {
graph: t.mobile.graph,
info: t.mobile.info,
files: t.mobile.files,
};
return (
<nav className="flex shrink-0 bg-surface border-t border-border-subtle">
{tabOrder.map((id) => {
const active = activeTab === id;
return (
<button
key={id}
type="button"
onClick={() => onTabChange(id)}
className={`relative flex-1 flex flex-col items-center justify-center gap-1 py-2.5 text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
active ? "text-accent" : "text-text-muted hover:text-text-secondary"
}`}
aria-current={active ? "page" : undefined}
>
<span className="w-5 h-5">{tabIcons[id]}</span>
{labels[id]}
{active && (
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-px bg-accent" />
)}
</button>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,266 @@
import { useEffect } from "react";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
import PersonaSelector from "./PersonaSelector";
import DiffToggle from "./DiffToggle";
import LayerLegend from "./LayerLegend";
import FilterPanel from "./FilterPanel";
import ExportMenu from "./ExportMenu";
import { ThemePicker } from "./ThemePicker";
interface Props {
open: boolean;
onClose: () => void;
onTogglePathFinder: () => void;
onShowKeyboardHelp: () => void;
}
interface NodeTypeFilterDef {
key: "code" | "config" | "docs" | "infra" | "data" | "domain" | "knowledge";
label: string;
color: string;
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted mb-3">
{children}
</h3>
);
}
export default function MobileDrawer({
open,
onClose,
onTogglePathFinder,
onShowKeyboardHelp,
}: Props) {
const graph = useDashboardStore((s) => s.graph);
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const viewMode = useDashboardStore((s) => s.viewMode);
const setViewMode = useDashboardStore((s) => s.setViewMode);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter);
const { t } = useI18n();
const structuralFilters: NodeTypeFilterDef[] = [
{ key: "code", label: t.nodeTypeLabels.code, color: "var(--color-node-file)" },
{ key: "config", label: t.nodeTypeLabels.config, color: "var(--color-node-config)" },
{ key: "docs", label: t.nodeTypeLabels.docs, color: "var(--color-node-document)" },
{ key: "infra", label: t.nodeTypeLabels.infra, color: "var(--color-node-service)" },
{ key: "data", label: t.nodeTypeLabels.data, color: "var(--color-node-table)" },
{ key: "domain", label: t.nodeTypeLabels.domain, color: "var(--color-node-concept)" },
{ key: "knowledge", label: t.nodeTypeLabels.knowledge, color: "var(--color-node-article)" },
];
const knowledgeFilters: NodeTypeFilterDef[] = [
{ key: "knowledge", label: t.nodeTypeLabels.all, color: "var(--color-node-article)" },
];
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
// Lock body scroll while open so the page behind doesn't drift
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [open]);
const filterDefs = isKnowledgeGraph ? knowledgeFilters : structuralFilters;
const showViewToggle = Boolean(graph && !isKnowledgeGraph && domainGraph);
return (
<div
className={`fixed inset-0 z-40 ${open ? "pointer-events-auto" : "pointer-events-none"}`}
aria-hidden={!open}
>
{/* Backdrop */}
<button
type="button"
aria-label="Close menu"
onClick={onClose}
className={`absolute inset-0 bg-black/65 backdrop-blur-sm transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0"
}`}
/>
{/* Panel */}
<aside
className={`absolute left-0 top-0 bottom-0 w-[86%] max-w-[360px] bg-surface border-r border-border-subtle flex flex-col transition-transform duration-300 ease-out ${
open ? "translate-x-0" : "-translate-x-full"
}`}
role="dialog"
aria-label="Settings"
>
{/* Drawer header */}
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle shrink-0">
<div>
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-accent">
{t.drawer.controls}
</span>
<h2 className="font-heading text-lg text-text-primary mt-0.5 leading-none">
{graph?.project.name ?? t.drawer.dashboard}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-elevated transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M6 18L18 6" />
</svg>
</button>
</header>
{/* Body */}
<div className="flex-1 overflow-auto px-5 py-5 space-y-7">
<section>
<SectionLabel>{t.drawer.role}</SectionLabel>
<PersonaSelector />
</section>
{showViewToggle && (
<section>
<SectionLabel>{t.drawer.view}</SectionLabel>
<div className="inline-flex items-center bg-elevated rounded-lg p-0.5">
<button
type="button"
onClick={() => setViewMode("domain")}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
viewMode === "domain"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.drawer.domain}
</button>
<button
type="button"
onClick={() => setViewMode("structural")}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
viewMode === "structural"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.drawer.structural}
</button>
</div>
</section>
)}
<section>
<SectionLabel>{t.drawer.diffOverlay}</SectionLabel>
<DiffToggle />
</section>
<section>
<SectionLabel>{t.drawer.nodeTypes}</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{filterDefs.map((cat) => {
const active = nodeTypeFilters[cat.key] !== false;
return (
<button
key={cat.key}
type="button"
onClick={() => toggleNodeTypeFilter(cat.key)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 whitespace-nowrap ${
active
? "border-border-medium bg-elevated text-text-secondary"
: "border-transparent bg-transparent text-text-muted/40 line-through"
}`}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: cat.color,
opacity: active ? 1 : 0.3,
}}
/>
{cat.label}
</button>
);
})}
</div>
</section>
{graph && (graph.layers?.length ?? 0) > 0 && (
<section>
<SectionLabel>{t.drawer.layers}</SectionLabel>
<div className="-mx-1">
<LayerLegend />
</div>
</section>
)}
<section>
<SectionLabel>{t.drawer.tools}</SectionLabel>
<div className="flex flex-wrap items-center gap-2">
<FilterPanel />
<ExportMenu />
<button
type="button"
onClick={() => {
onTogglePathFinder();
onClose();
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
{t.drawer.path}
</button>
<ThemePicker />
<button
type="button"
onClick={() => {
onShowKeyboardHelp();
onClose();
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
aria-label={t.drawer.help}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{t.drawer.help}
</button>
</div>
</section>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { lazy, Suspense, useEffect, useState } from "react";
import type { GraphIssue } from "@understand-anything/core/schema";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
import GraphView from "./GraphView";
import DomainGraphView from "./DomainGraphView";
import KnowledgeGraphView from "./KnowledgeGraphView";
import SearchBar from "./SearchBar";
import NodeInfo from "./NodeInfo";
import ProjectOverview from "./ProjectOverview";
import FileExplorer from "./FileExplorer";
import WarningBanner from "./WarningBanner";
import MobileBottomNav from "./MobileBottomNav";
import type { MobileTab } from "./MobileBottomNav";
import MobileDrawer from "./MobileDrawer";
const CodeViewer = lazy(() => import("./CodeViewer"));
const LearnPanel = lazy(() => import("./LearnPanel"));
const PathFinderModal = lazy(() => import("./PathFinderModal"));
const KeyboardShortcutsHelp = lazy(() => import("./KeyboardShortcutsHelp"));
interface Props {
accessToken: string;
showKeyboardHelp: boolean;
setShowKeyboardHelp: (value: boolean) => void;
loadError: string | null;
allIssues: GraphIssue[];
shortcuts: import("../hooks/useKeyboardShortcuts").KeyboardShortcut[];
}
export default function MobileLayout({
accessToken,
showKeyboardHelp,
setShowKeyboardHelp,
loadError,
allIssues,
shortcuts,
}: Props) {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const tourActive = useDashboardStore((s) => s.tourActive);
const persona = useDashboardStore((s) => s.persona);
const viewMode = useDashboardStore((s) => s.viewMode);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<MobileTab>("graph");
const [drawerOpen, setDrawerOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Auto-pivot to Info when a node is selected — keeps feedback visible
// on a small screen where graph and sidebar can't coexist
useEffect(() => {
if (selectedNodeId) setActiveTab("info");
}, [selectedNodeId]);
// When a code viewer opens (e.g. from the Files tab) keep focus there
useEffect(() => {
if (codeViewerOpen) setSearchOpen(false);
}, [codeViewerOpen]);
const isLearnMode = tourActive || persona === "junior";
const infoContent = (
<>
{selectedNodeId && <NodeInfo />}
{isLearnMode && (
<Suspense fallback={null}>
<LearnPanel />
</Suspense>
)}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
return (
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Top bar */}
<header className="flex items-center gap-2 px-3 h-12 shrink-0 bg-surface border-b border-border-subtle">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
aria-label="Open menu"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<h1 className="font-heading text-base flex-1 min-w-0 truncate text-center text-text-primary tracking-wide">
{graph?.project.name ?? t.common.appName}
</h1>
<button
type="button"
onClick={() => setSearchOpen((prev) => !prev)}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-colors -mr-1 ${
searchOpen
? "text-accent bg-accent/15"
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
}`}
aria-label={searchOpen ? "Hide search" : "Show search"}
aria-pressed={searchOpen}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
</header>
{/* Search (collapsible) */}
{searchOpen && <SearchBar />}
{/* Validation warnings */}
{allIssues.length > 0 && !loadError && <WarningBanner issues={allIssues} />}
{/* Load error */}
{loadError && (
<div className="px-4 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
{loadError}
</div>
)}
{/* Tabbed content — all panes stay mounted to preserve layout/state.
Inactive panes are kept in the layout (not display:none) so that
ReactFlow keeps real dimensions and pinch/pan don't collapse on
tab switch. */}
<div className="flex-1 min-h-0 relative">
<div
className={`absolute inset-0 ${
activeTab === "graph" ? "" : "invisible pointer-events-none"
}`}
aria-hidden={activeTab !== "graph"}
>
{viewMode === "knowledge" ? (
<KnowledgeGraphView />
) : viewMode === "domain" && domainGraph ? (
<DomainGraphView />
) : (
<GraphView />
)}
</div>
<div
className={`absolute inset-0 overflow-auto bg-surface ${
activeTab === "info" ? "" : "invisible pointer-events-none"
}`}
aria-hidden={activeTab !== "info"}
>
{infoContent}
</div>
<div
className={`absolute inset-0 overflow-auto bg-surface ${
activeTab === "files" ? "" : "invisible pointer-events-none"
}`}
aria-hidden={activeTab !== "files"}
>
<FileExplorer />
</div>
</div>
{/* Bottom tab nav */}
<MobileBottomNav activeTab={activeTab} onTabChange={setActiveTab} />
{/* Drawer */}
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onTogglePathFinder={togglePathFinder}
onShowKeyboardHelp={() => setShowKeyboardHelp(true)}
/>
{/* Code viewer — always fullscreen on mobile */}
{codeViewerOpen && (
<div
className="fixed inset-0 z-50 flex bg-black/70 backdrop-blur-sm p-2 sm:p-4"
onMouseDown={closeCodeViewer}
>
<div
className="flex-1 rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
onMouseDown={(event) => event.stopPropagation()}
>
<Suspense fallback={null}>
<CodeViewer
accessToken={accessToken}
presentation="modal"
onClose={closeCodeViewer}
/>
</Suspense>
</div>
</div>
)}
{/* Keyboard help (mobile reads it as a quick reference too) */}
{showKeyboardHelp && (
<Suspense fallback={null}>
<KeyboardShortcutsHelp
shortcuts={shortcuts}
onClose={() => setShowKeyboardHelp(false)}
/>
</Suspense>
)}
{/* Path finder */}
{pathFinderOpen && (
<Suspense fallback={null}>
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
</Suspense>
)}
</div>
);
}

View File

@@ -0,0 +1,536 @@
import { useState } from "react";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
import type { NodeType, EdgeType, KnowledgeGraph, GraphNode } from "@understand-anything/core/types";
// Badge color classes keyed by NodeType — must be kept in sync with core NodeType union.
const typeBadgeColors: Record<NodeType, string> = {
file: "text-node-file border border-node-file/30 bg-node-file/10",
function: "text-node-function border border-node-function/30 bg-node-function/10",
class: "text-node-class border border-node-class/30 bg-node-class/10",
module: "text-node-module border border-node-module/30 bg-node-module/10",
concept: "text-node-concept border border-node-concept/30 bg-node-concept/10",
config: "text-node-config border border-node-config/30 bg-node-config/10",
document: "text-node-document border border-node-document/30 bg-node-document/10",
service: "text-node-service border border-node-service/30 bg-node-service/10",
table: "text-node-table border border-node-table/30 bg-node-table/10",
endpoint: "text-node-endpoint border border-node-endpoint/30 bg-node-endpoint/10",
pipeline: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10",
schema: "text-node-schema border border-node-schema/30 bg-node-schema/10",
resource: "text-node-resource border border-node-resource/30 bg-node-resource/10",
domain: "text-node-concept border border-node-concept/30 bg-node-concept/10",
flow: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10",
step: "text-node-function border border-node-function/30 bg-node-function/10",
article: "text-node-article border border-node-article/30 bg-node-article/10",
entity: "text-node-entity border border-node-entity/30 bg-node-entity/10",
topic: "text-node-topic border border-node-topic/30 bg-node-topic/10",
claim: "text-node-claim border border-node-claim/30 bg-node-claim/10",
source: "text-node-source border border-node-source/30 bg-node-source/10",
};
const complexityBadgeColors: Record<string, string> = {
simple: "text-node-function border border-node-function/30 bg-node-function/10",
moderate: "text-accent-dim border border-accent-dim/30 bg-accent-dim/10",
complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10",
};
function getDirectionalLabel(edgeType: string, isSource: boolean, t: ReturnType<typeof useI18n>["t"]): string {
const labels = t.edgeLabels[edgeType as EdgeType];
if (!labels) {
const formatted = edgeType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return isSource ? formatted : `${formatted} (reverse)`;
}
return isSource ? labels.forward : labels.backward;
}
function KnowledgeNodeDetails({ node, graph }: { node: GraphNode; graph: KnowledgeGraph }) {
const navigateToNode = useDashboardStore((s) => s.navigateToNode);
const { t } = useI18n();
const meta = node.knowledgeMeta;
// Wikilinks (outgoing related edges)
const wikilinks = graph.edges
.filter((e) => e.type === "related" && e.source === node.id)
.map((e) => graph.nodes.find((n) => n.id === e.target))
.filter((n): n is GraphNode => n !== undefined);
// Backlinks (incoming related edges)
const backlinks = graph.edges
.filter((e) => e.type === "related" && e.target === node.id)
.map((e) => graph.nodes.find((n) => n.id === e.source))
.filter((n): n is GraphNode => n !== undefined);
// Category
const categoryEdge = graph.edges.find(
(e) => e.type === "categorized_under" && e.source === node.id
);
const categoryNode = categoryEdge
? graph.nodes.find((n) => n.id === categoryEdge.target)
: null;
return (
<div className="space-y-3">
{categoryNode && (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.category}</h4>
<button
type="button"
onClick={() => navigateToNode(categoryNode.id)}
className="text-[11px] px-2 py-0.5 rounded bg-elevated text-accent hover:text-accent-bright transition-colors"
>
{categoryNode.name}
</button>
</div>
)}
{meta?.wikilinks && meta.wikilinks.length > 0 && (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">
{t.nodeInfo.wikilinks} ({wikilinks.length})
</h4>
<div className="space-y-1 max-h-[200px] overflow-auto">
{wikilinks.map((n) => (
<button
key={n.id}
type="button"
onClick={() => navigateToNode(n.id)}
className="block w-full text-left px-2 py-1.5 rounded bg-elevated hover:bg-accent/10 text-[11px] text-text-secondary hover:text-accent transition-colors truncate"
>
{n.name}
</button>
))}
</div>
</div>
)}
{backlinks.length > 0 && (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">
{t.nodeInfo.backlinks} ({backlinks.length})
</h4>
<div className="space-y-1 max-h-[200px] overflow-auto">
{backlinks.map((n) => (
<button
key={n.id}
type="button"
onClick={() => navigateToNode(n.id)}
className="block w-full text-left px-2 py-1.5 rounded bg-elevated hover:bg-accent/10 text-[11px] text-text-secondary hover:text-accent transition-colors truncate"
>
{n.name}
</button>
))}
</div>
</div>
)}
{meta?.content && (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.common.preview}</h4>
<div className="text-[11px] text-text-secondary leading-relaxed bg-elevated rounded-lg p-3 max-h-[520px] overflow-auto whitespace-pre-wrap font-mono">
{meta.content}
</div>
</div>
)}
</div>
);
}
function DomainNodeDetails({ node, graph }: { node: GraphNode; graph: KnowledgeGraph }) {
const navigateToDomain = useDashboardStore((s) => s.navigateToDomain);
const selectNode = useDashboardStore((s) => s.selectNode);
const { t } = useI18n();
const meta = node.domainMeta;
if (node.type === "domain") {
const flows = graph.edges
.filter((e) => e.type === "contains_flow" && e.source === node.id)
.map((e) => graph.nodes.find((n) => n.id === e.target))
.filter((n): n is GraphNode => n !== undefined);
return (
<div className="space-y-3">
{Array.isArray(meta?.entities) && meta.entities.length > 0 ? (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.entities}</h4>
<div className="flex flex-wrap gap-1">
{meta.entities.map((e) => (
<span key={e} className="text-[11px] px-2 py-0.5 rounded bg-elevated text-text-secondary">{e}</span>
))}
</div>
</div>
) : null}
{Array.isArray(meta?.businessRules) && meta.businessRules.length > 0 ? (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.businessRules}</h4>
<ul className="text-[11px] text-text-secondary space-y-1">
{meta.businessRules.map((r, i) => (
<li key={i} className="flex gap-1.5"><span className="text-accent shrink-0">-</span>{r}</li>
))}
</ul>
</div>
) : null}
{Array.isArray(meta?.crossDomainInteractions) && meta.crossDomainInteractions.length > 0 ? (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.crossDomain}</h4>
<ul className="text-[11px] text-text-secondary space-y-1">
{meta.crossDomainInteractions.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</div>
) : null}
{flows.length > 0 && (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.flows}</h4>
<div className="space-y-1">
{flows.map((f) => (
<button
key={f.id}
type="button"
onClick={() => { navigateToDomain(node.id); selectNode(f.id); }}
className="block w-full text-left px-2 py-1.5 rounded bg-elevated hover:bg-accent/10 text-[11px] text-text-secondary hover:text-accent transition-colors"
>
{f.name}
</button>
))}
</div>
</div>
)}
</div>
);
}
if (node.type === "flow") {
const steps = graph.edges
.filter((e) => e.type === "flow_step" && e.source === node.id)
.sort((a, b) => a.weight - b.weight)
.map((e) => graph.nodes.find((n) => n.id === e.target))
.filter((n): n is GraphNode => n !== undefined);
return (
<div className="space-y-3">
{meta?.entryPoint ? (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.entryPoint}</h4>
<div className="text-[11px] font-mono text-accent">{meta.entryPoint}</div>
</div>
) : null}
{steps.length > 0 && (
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.steps}</h4>
<ol className="space-y-1">
{steps.map((s, i) => (
<li key={s.id}>
<button
type="button"
onClick={() => selectNode(s.id)}
className="block w-full text-left px-2 py-1.5 rounded bg-elevated hover:bg-accent/10 text-[11px] transition-colors"
>
<span className="text-accent/60 mr-1.5">{i + 1}.</span>
<span className="text-text-secondary hover:text-accent">{s.name}</span>
</button>
</li>
))}
</ol>
</div>
)}
</div>
);
}
if (node.type === "step") {
if (!node.filePath) return null;
return (
<div className="space-y-3">
<div>
<h4 className="text-[10px] uppercase tracking-wider text-text-muted mb-1">{t.nodeInfo.implementation}</h4>
<div className="text-[11px] font-mono text-text-secondary">
{node.filePath}
{node.lineRange && <span className="text-text-muted">:{node.lineRange[0]}-{node.lineRange[1]}</span>}
</div>
</div>
</div>
);
}
return null;
}
export default function NodeInfo() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const nodeHistory = useDashboardStore((s) => s.nodeHistory);
const goBackNode = useDashboardStore((s) => s.goBackNode);
const [languageExpanded, setLanguageExpanded] = useState(true);
const { t } = useI18n();
const navigateToNode = useDashboardStore((s) => s.navigateToNode);
const navigateToHistoryIndex = useDashboardStore((s) => s.navigateToHistoryIndex);
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const viewMode = useDashboardStore((s) => s.viewMode);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const activeGraph = viewMode === "domain" && domainGraph ? domainGraph : graph;
const node = activeGraph?.nodes.find((n) => n.id === selectedNodeId) ?? null;
// Resolve history node names for the breadcrumb trail
const historyNodes = nodeHistory.map((id) => {
const n = activeGraph?.nodes.find((gn) => gn.id === id);
return { id, name: n?.name ?? id };
});
if (!node) {
return (
<div className="h-full w-full flex items-center justify-center bg-surface">
<p className="text-text-muted text-sm">{t.common.selectNode}</p>
</div>
);
}
const allEdges = activeGraph?.edges ?? [];
const connections = allEdges.filter(
(e) => e.source === node.id || e.target === node.id,
);
// Separate child nodes (contained IN this file) from other connections
const childEdges = connections.filter(
(e) => e.type === "contains" && e.source === node.id,
);
const otherConnections = connections.filter(
(e) => !(e.type === "contains" && e.source === node.id),
);
// Resolve child nodes
const childNodes = childEdges
.map((e) => activeGraph?.nodes.find((n) => n.id === e.target))
.filter((n): n is GraphNode => n !== undefined);
const knownType = node.type as NodeType;
const typeBadge = typeBadgeColors[knownType] ?? typeBadgeColors.file;
const complexityBadge =
complexityBadgeColors[node.complexity] ?? complexityBadgeColors.simple;
if (import.meta.env.DEV && !(knownType in typeBadgeColors)) {
console.warn(`[NodeInfo] Unknown node type "${node.type}" — using "file" badge colors`);
}
return (
<div className="h-full w-full overflow-auto p-5 animate-fade-slide-in">
{/* Navigation history trail */}
{historyNodes.length > 0 && (
<div className="mb-3 flex items-center gap-1 flex-wrap">
<button
onClick={goBackNode}
className="text-[10px] font-semibold text-gold hover:text-gold-bright transition-colors flex items-center gap-1"
>
<span></span>
<span>{t.common.back}</span>
</button>
<span className="text-text-muted text-[10px]"></span>
{historyNodes.slice(-3).map((h, i, arr) => (
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
<button
onClick={() => {
const fullIdx = historyNodes.length - arr.length + i;
navigateToHistoryIndex(fullIdx);
}}
className="text-[10px] text-text-muted hover:text-gold transition-colors truncate max-w-[80px]"
title={h.name}
>
{h.name}
</button>
{i < arr.length - 1 && (
<span className="text-text-muted text-[10px]"></span>
)}
</span>
))}
<span className="text-text-muted text-[10px]"></span>
<span className="text-[10px] text-text-primary font-medium truncate max-w-[80px]">
{node.name}
</span>
</div>
)}
<div className="flex items-center gap-2 mb-3">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ${typeBadge}`}
>
{node.type}
</span>
<span
className={`text-[10px] font-semibold px-2 py-0.5 rounded ${complexityBadge}`}
>
{node.complexity}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-heading text-text-primary">{node.name}</h2>
<button
onClick={() => setFocusNode(focusNodeId === node.id ? null : node.id)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1 rounded transition-colors ${
focusNodeId === node.id
? "bg-gold/20 text-gold border border-gold/40"
: "text-text-muted border border-border-subtle hover:text-gold hover:border-gold/30"
}`}
>
{focusNodeId === node.id ? t.common.unfocus : t.common.focus}
</button>
</div>
<p className="text-sm text-text-secondary mb-4 leading-relaxed">
{node.summary}
</p>
{node.filePath && (
<div className="text-xs text-text-secondary mb-4 rounded-lg border border-border-subtle bg-elevated/60 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-text-muted mb-1">{t.common.file}</div>
<div className="font-mono truncate" title={node.filePath}>
{node.filePath}
{node.lineRange && (
<span className="ml-2 text-text-muted">
L{node.lineRange[0]}-{node.lineRange[1]}
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => openCodeViewer(node.id)}
className="shrink-0 text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1 rounded border border-accent/30 text-accent hover:text-accent-bright hover:border-accent/60 transition-colors"
>
</button>
</div>
</div>
)}
{node.languageNotes && (
<div className="mb-4">
<button
onClick={() => setLanguageExpanded(!languageExpanded)}
className="flex items-center gap-1.5 text-xs font-semibold text-accent uppercase tracking-wider mb-2 hover:text-accent-bright transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${languageExpanded ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{t.nodeInfo.languageConcepts}
</button>
{languageExpanded && (
<div className="bg-accent/5 border border-accent/20 rounded-lg p-3">
<p className="text-sm text-text-secondary leading-relaxed">
{node.languageNotes}
</p>
</div>
)}
</div>
)}
{node.tags.length > 0 && (
<div className="mb-4">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
{t.common.tags}
</h3>
<div className="flex flex-wrap gap-1.5">
{node.tags.map((tag) => (
<span
key={tag}
className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Knowledge-specific details */}
{activeGraph && node && (node.type === "article" || node.type === "entity" || node.type === "topic" || node.type === "claim" || node.type === "source" || node.knowledgeMeta) && (
<KnowledgeNodeDetails node={node} graph={activeGraph} />
)}
{/* Domain-specific details */}
{activeGraph && node && (node.type === "domain" || node.type === "flow" || node.type === "step") && (
<DomainNodeDetails node={node} graph={activeGraph} />
)}
{/* Child classes/functions within this file */}
{childNodes.length > 0 && (
<div className="mb-4">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
{t.nodeInfo.definedInThisFile} ({childNodes.length})
</h3>
<div className="space-y-1">
{childNodes.map((child) => {
if (!child) return null;
const childTypeBadge = typeBadgeColors[child.type as NodeType] ?? typeBadgeColors.file;
const childComplexity = complexityBadgeColors[child.complexity] ?? complexityBadgeColors.simple;
return (
<div
key={child.id}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
onClick={() => navigateToNode(child.id)}
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${childTypeBadge}`}>
{child.type}
</span>
<span className="text-text-primary truncate">{child.name}</span>
<span className={`text-[9px] ml-auto ${childComplexity} px-1 py-0.5 rounded`}>
{child.complexity}
</span>
</div>
{child.summary && (
<p className="text-[11px] text-text-muted mt-1 line-clamp-1 pl-1">
{child.summary}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Other connections (excluding "contains" children) */}
{otherConnections.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
{t.common.connections} ({otherConnections.length})
</h3>
<div className="space-y-1.5">
{otherConnections.map((edge, i) => {
const isSource = edge.source === node.id;
const otherId = isSource ? edge.target : edge.source;
const otherNode = activeGraph?.nodes.find((n) => n.id === otherId);
const dirLabel = getDirectionalLabel(edge.type, isSource, t);
const arrow = isSource ? "\u2192" : "\u2190";
return (
<div
key={i}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2 cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
onClick={() => {
navigateToNode(otherId);
}}
>
<span className="text-gold font-mono">{arrow}</span>
<span className="text-text-muted">{dirLabel}</span>
<span className="text-text-primary truncate">
{otherNode?.name ?? otherId}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import type { CustomNodeData } from "./CustomNode";
interface NodeTooltipProps {
data: CustomNodeData;
nodeId: string;
incomingCount: number;
outgoingCount: number;
tags?: string[];
}
export default function NodeTooltip({
data,
nodeId,
incomingCount,
outgoingCount,
tags = [],
}: NodeTooltipProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
useEffect(() => {
const handleMouseMove = (e: Event) => {
const me = e as globalThis.MouseEvent;
setPosition({ x: me.clientX, y: me.clientY });
};
const showTooltip = () => setVisible(true);
const hideTooltip = () => setVisible(false);
// Find the node element via data-id (React Flow convention)
const nodeElement = document.querySelector(`[data-id="${CSS.escape(nodeId)}"]`);
if (nodeElement) {
nodeElement.addEventListener("mouseenter", showTooltip);
nodeElement.addEventListener("mouseleave", hideTooltip);
nodeElement.addEventListener("mousemove", handleMouseMove);
return () => {
nodeElement.removeEventListener("mouseenter", showTooltip);
nodeElement.removeEventListener("mouseleave", hideTooltip);
nodeElement.removeEventListener("mousemove", handleMouseMove);
};
}
}, [nodeId]);
if (!visible) return null;
const totalConnections = incomingCount + outgoingCount;
return (
<div
className="fixed z-[9999] pointer-events-none"
style={{
left: position.x + 16,
top: position.y + 16,
}}
>
<div className="glass-heavy rounded-lg shadow-2xl p-3 max-w-xs animate-fade-slide-in">
{/* Header */}
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-border-subtle">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gold">
{data.nodeType}
</span>
{data.complexity && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-elevated text-text-muted font-mono">
{data.complexity}
</span>
)}
</div>
{/* Name */}
<h4 className="text-sm font-heading text-text-primary mb-2 break-words">
{data.label}
</h4>
{/* Connections */}
<div className="flex items-center gap-4 mb-2 text-xs">
<div className="flex items-center gap-1">
<svg className="w-3 h-3 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
<span className="text-text-secondary">{incomingCount} in</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" transform="rotate(180 10 10)" />
</svg>
<span className="text-text-secondary">{outgoingCount} out</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-3 h-3 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-gold font-medium">{totalConnections}</span>
</div>
</div>
{/* Summary */}
{data.summary && (
<p className="text-xs text-text-secondary leading-relaxed mb-2">
{data.summary.length > 120 ? data.summary.slice(0, 120) + "..." : data.summary}
</p>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-2 border-t border-border-subtle">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-[9px] px-1.5 py-0.5 rounded-full bg-gold/10 text-gold border border-gold/30"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="text-[9px] text-text-muted">+{tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useState } from "react";
import { useI18n } from "../contexts/I18nContext";
/**
* First-visit onboarding overlay (controlled).
*
* Parent owns the visibility + persistence state (see App.tsx). This component
* only renders the modal and reports the user's intent via onDismiss:
* - onDismiss(true) → "Skip" / Finish — parent should persist.
* - onDismiss(false) → backdrop click / Escape — parent should close without persisting.
*
* Force-show is handled by the parent (see `shouldShowOnboarding` in App.tsx).
*/
interface Props {
onDismiss: (remember: boolean) => void;
}
const TITLE_ID = "ua-onboarding-title";
export default function OnboardingOverlay({ onDismiss }: Props) {
const { t } = useI18n();
const STEPS = t.onboarding.steps;
const [stepIdx, setStepIdx] = useState(0);
// Capture-phase Escape handler — runs before the global keydown chain so we
// can stopPropagation() and prevent it from also firing.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onDismiss(false);
}
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, [onDismiss]);
const isFirst = stepIdx === 0;
const isLast = stepIdx === STEPS.length - 1;
const step = STEPS[stepIdx];
return (
<div
style={overlayStyle}
onClick={(e) => {
if (e.target === e.currentTarget) onDismiss(false);
}}
>
<style>{KEYFRAMES}</style>
<div
role="dialog"
aria-modal="true"
aria-labelledby={TITLE_ID}
style={cardStyle}
>
<div style={tagStyle}>
<span style={numStyle}>0{stepIdx + 1}</span>
<span> / 0{STEPS.length}</span>
<span style={dotStyle} />
<span>{t.onboarding.header}</span>
</div>
<h2 id={TITLE_ID} style={titleStyle}>
{step.title}
</h2>
<p style={bodyStyle}>{step.body}</p>
{step.hint && (
<blockquote style={hintStyle}>
<span style={{ color: "var(--color-accent)", marginRight: 8 }}>·</span>
{step.hint}
</blockquote>
)}
<div style={progressTrackStyle}>
{STEPS.map((_, i) => (
<div
key={i}
style={{
...dotProgressStyle,
background:
i === stepIdx
? "var(--color-accent)"
: "var(--color-border-medium)",
width: i === stepIdx ? 28 : 6,
}}
/>
))}
</div>
<div style={btnRowStyle}>
<button
type="button"
onClick={() => onDismiss(true)}
style={{ ...btnStyle, ...btnGhostStyle }}
>
{t.onboarding.skipForever}
</button>
<div style={{ flex: 1 }} />
{!isFirst && (
<button
type="button"
onClick={() => setStepIdx(stepIdx - 1)}
style={{ ...btnStyle, ...btnGhostStyle }}
>
{t.onboarding.prev}
</button>
)}
{!isLast ? (
<button
type="button"
onClick={() => setStepIdx(stepIdx + 1)}
style={{ ...btnStyle, ...btnPrimaryStyle }}
>
{t.onboarding.next}
</button>
) : (
<button
type="button"
onClick={() => onDismiss(true)}
style={{ ...btnStyle, ...btnPrimaryStyle }}
>
{t.onboarding.finish}
</button>
)}
</div>
</div>
</div>
);
}
const KEYFRAMES = `@keyframes ua-fade-in { from { opacity: 0 } to { opacity: 1 } }`;
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.78)",
backdropFilter: "blur(6px)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
fontFamily: "var(--font-sans)",
animation: "ua-fade-in 0.4s cubic-bezier(0.22, 1, 0.36, 1)",
};
const cardStyle: React.CSSProperties = {
background: "var(--color-elevated)",
color: "var(--color-text-primary)",
maxWidth: 580,
width: "100%",
padding: "48px 48px 36px",
border: "1px solid var(--color-border-subtle)",
borderTop: "2px solid var(--color-accent)",
position: "relative",
};
const tagStyle: React.CSSProperties = {
fontSize: "0.72rem",
letterSpacing: "0.3em",
color: "var(--color-text-muted)",
textTransform: "uppercase",
marginBottom: 24,
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
};
const numStyle: React.CSSProperties = {
fontFamily: "var(--font-heading)",
color: "var(--color-accent)",
fontSize: "0.9rem",
letterSpacing: "0.1em",
marginRight: 4,
};
const dotStyle: React.CSSProperties = {
width: 4,
height: 4,
background: "var(--color-accent)",
borderRadius: "50%",
margin: "0 12px",
};
const titleStyle: React.CSSProperties = {
fontFamily: "var(--font-heading)",
fontSize: "1.7rem",
fontWeight: 400,
letterSpacing: "0.02em",
lineHeight: 1.3,
marginBottom: 16,
color: "var(--color-text-primary)",
};
const bodyStyle: React.CSSProperties = {
fontSize: "0.98rem",
lineHeight: 1.7,
color: "var(--color-text-secondary)",
marginBottom: 0,
};
const hintStyle: React.CSSProperties = {
margin: "20px 0 0",
padding: "12px 18px",
borderLeft: "2px solid var(--color-border-medium)",
background: "var(--color-accent-overlay-bg)",
fontSize: "0.86rem",
color: "var(--color-accent)",
fontStyle: "italic",
};
const progressTrackStyle: React.CSSProperties = {
display: "flex",
gap: 6,
marginTop: 36,
marginBottom: 28,
};
const dotProgressStyle: React.CSSProperties = {
height: 4,
borderRadius: 2,
transition: "width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s",
};
const btnRowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
};
const btnStyle: React.CSSProperties = {
padding: "10px 22px",
fontSize: "0.82rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
border: "1px solid",
cursor: "pointer",
fontFamily: "inherit",
transition: "all 0.3s cubic-bezier(0.22, 1, 0.36, 1)",
fontWeight: 400,
};
const btnGhostStyle: React.CSSProperties = {
background: "transparent",
borderColor: "var(--color-border-medium)",
color: "var(--color-text-muted)",
};
const btnPrimaryStyle: React.CSSProperties = {
background: "var(--color-accent)",
borderColor: "var(--color-accent)",
color: "var(--color-root)",
fontWeight: 500,
};

View File

@@ -0,0 +1,311 @@
import { useEffect, useRef, useState } from "react";
import { useDashboardStore } from "../store";
interface PathFinderModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function PathFinderModal({ isOpen, onClose }: PathFinderModalProps) {
const graph = useDashboardStore((s) => s.graph);
const selectNode = useDashboardStore((s) => s.selectNode);
const [fromNodeId, setFromNodeId] = useState("");
const [toNodeId, setToNodeId] = useState("");
const [path, setPath] = useState<string[] | null>(null);
const [searching, setSearching] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen || !graph) return null;
const nodes = graph.nodes;
const edges = graph.edges;
// BFS to find shortest path
const findPath = () => {
if (!fromNodeId || !toNodeId || fromNodeId === toNodeId) {
setPath(null);
return;
}
setSearching(true);
// Build adjacency list (bidirectional traversal for path finding)
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (!adjacency.has(edge.source)) {
adjacency.set(edge.source, []);
}
adjacency.get(edge.source)!.push(edge.target);
// Also traverse in reverse so we can find paths through backward edges
if (!adjacency.has(edge.target)) {
adjacency.set(edge.target, []);
}
adjacency.get(edge.target)!.push(edge.source);
}
// BFS
const queue: Array<{ nodeId: string; path: string[] }> = [
{ nodeId: fromNodeId, path: [fromNodeId] },
];
const visited = new Set<string>([fromNodeId]);
while (queue.length > 0) {
const { nodeId, path: currentPath } = queue.shift()!;
if (nodeId === toNodeId) {
setPath(currentPath);
setSearching(false);
return;
}
const neighbors = adjacency.get(nodeId) ?? [];
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push({ nodeId: neighbor, path: [...currentPath, neighbor] });
}
}
}
// No path found
setPath([]);
setSearching(false);
};
const handleNodeClick = (nodeId: string) => {
selectNode(nodeId);
onClose();
};
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-root/80 backdrop-blur-sm">
<div
ref={modalRef}
className="glass-heavy rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden animate-fade-slide-in"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
<h2 className="font-heading text-xl text-text-primary">Dependency Path Finder</h2>
</div>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4 overflow-y-auto max-h-[calc(80vh-180px)]">
<p className="text-sm text-text-secondary">
Find the shortest path between two nodes in the dependency graph.
</p>
{/* From Node */}
<div>
<label className="block text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
From Node
</label>
<select
value={fromNodeId}
onChange={(e) => {
setFromNodeId(e.target.value);
setPath(null);
}}
className="w-full bg-elevated text-text-primary text-sm rounded-lg px-3 py-2 border border-border-subtle focus:outline-none focus:border-gold/50"
>
<option value="">Select a node...</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
</div>
{/* To Node */}
<div>
<label className="block text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">
To Node
</label>
<select
value={toNodeId}
onChange={(e) => {
setToNodeId(e.target.value);
setPath(null);
}}
className="w-full bg-elevated text-text-primary text-sm rounded-lg px-3 py-2 border border-border-subtle focus:outline-none focus:border-gold/50"
>
<option value="">Select a node...</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({node.type})
</option>
))}
</select>
</div>
{/* Find Path Button */}
<button
onClick={findPath}
disabled={!fromNodeId || !toNodeId || fromNodeId === toNodeId || searching}
className="w-full bg-gold/10 border border-gold/30 text-gold text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-gold/20 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{searching ? "Searching..." : "Find Path"}
</button>
{/* Path Result */}
{path !== null && (
<div className="mt-4">
{path.length === 0 ? (
<div className="bg-red-900/20 border border-red-700/50 rounded-lg p-4 text-center">
<svg
className="w-8 h-8 text-red-400 mx-auto mb-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm text-red-200">No path found between these nodes.</p>
</div>
) : (
<div className="bg-elevated border border-border-subtle rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<svg
className="w-4 h-4 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="text-sm font-semibold text-text-primary">
Path Found ({path.length} nodes)
</h3>
</div>
<div className="space-y-2">
{path.map((nodeId, idx) => {
const node = nodeMap.get(nodeId);
if (!node) return null;
const isLast = idx === path.length - 1;
return (
<div key={nodeId}>
<button
onClick={() => handleNodeClick(nodeId)}
className="w-full flex items-center gap-3 p-2 bg-surface rounded-lg hover:bg-elevated transition-colors text-left"
>
<div className="w-6 h-6 shrink-0 rounded-full bg-gold/20 flex items-center justify-center text-xs font-bold text-gold">
{idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-text-primary truncate">{node.name}</div>
<div className="text-xs text-text-muted capitalize">{node.type}</div>
</div>
<svg
className="w-4 h-4 text-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{!isLast && (
<div className="flex items-center justify-center my-1">
<svg
className="w-4 h-4 text-gold"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-border-subtle">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
import type { Persona } from "../store";
export default function PersonaSelector() {
const persona = useDashboardStore((s) => s.persona);
const setPersona = useDashboardStore((s) => s.setPersona);
const { t } = useI18n();
const personas: { id: Persona; label: string; description: string }[] = [
{
id: "non-technical",
label: t.personaSelector.overview,
description: t.personaSelector.overviewDesc,
},
{
id: "junior",
label: t.personaSelector.learn,
description: t.personaSelector.learnDesc,
},
{
id: "experienced",
label: t.personaSelector.deepDive,
description: t.personaSelector.deepDiveDesc,
},
];
return (
<div className="flex items-center gap-1 bg-elevated rounded-lg p-0.5">
{personas.map((p) => (
<button
key={p.id}
onClick={() => setPersona(p.id)}
title={p.description}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
persona === p.id
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary hover:bg-surface"
}`}
>
{p.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
export interface PortalNodeData extends Record<string, unknown> {
targetLayerId: string;
targetLayerName: string;
connectionCount: number;
layerColorIndex: number;
onNavigate: (layerId: string) => void;
}
export type PortalFlowNode = Node<PortalNodeData, "portal">;
function PortalNode({
data,
}: NodeProps<PortalFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
return (
<div
className="relative rounded-lg bg-elevated/60 overflow-hidden cursor-pointer transition-all duration-200 hover:bg-elevated/80"
style={{
width: 220,
border: `2px dashed ${color.border}`,
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
}}
onClick={() => data.onNavigate(data.targetLayerId)}
>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="px-3 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: color.label }}
/>
<span className="text-sm text-text-primary truncate">
{data.targetLayerName}
</span>
</div>
<span className="text-text-muted ml-2 shrink-0"></span>
</div>
<div className="text-[10px] text-text-muted mt-1 pl-4">
{data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
export default memo(PortalNode);

View File

@@ -0,0 +1,222 @@
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
export default function ProjectOverview() {
const graph = useDashboardStore((s) => s.graph);
const startTour = useDashboardStore((s) => s.startTour);
const { t } = useI18n();
if (!graph) {
return (
<div className="h-full w-full flex items-center justify-center">
<p className="text-text-muted text-sm">{t.common.loading}</p>
</div>
);
}
const { project, nodes, edges, layers } = graph;
const hasTour = graph.tour.length > 0;
const typeCounts: Record<string, number> = {};
for (const node of nodes) {
typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1;
}
const complexityCounts: Record<string, number> = { simple: 0, moderate: 0, complex: 0 };
for (const node of nodes) {
if (node.complexity) {
complexityCounts[node.complexity] = (complexityCounts[node.complexity] ?? 0) + 1;
}
}
const nodeConnections = new Map<string, number>();
for (const edge of edges) {
nodeConnections.set(edge.source, (nodeConnections.get(edge.source) ?? 0) + 1);
nodeConnections.set(edge.target, (nodeConnections.get(edge.target) ?? 0) + 1);
}
const topNodes = Array.from(nodeConnections.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([nodeId, count]) => {
const node = nodes.find((n) => n.id === nodeId);
return { id: nodeId, name: node?.name ?? nodeId, count };
});
const avgConnections = nodes.length > 0 ? (edges.length * 2 / nodes.length).toFixed(1) : "0";
const categoryBreakdown = [
{ label: t.projectOverview.code, color: "var(--color-node-file)", count: (typeCounts["file"] ?? 0) + (typeCounts["function"] ?? 0) + (typeCounts["class"] ?? 0) + (typeCounts["module"] ?? 0) + (typeCounts["concept"] ?? 0) },
{ label: t.projectOverview.config, color: "var(--color-node-config)", count: typeCounts["config"] ?? 0 },
{ label: t.projectOverview.docs, color: "var(--color-node-document)", count: typeCounts["document"] ?? 0 },
{ label: t.projectOverview.infra, color: "var(--color-node-service)", count: (typeCounts["service"] ?? 0) + (typeCounts["resource"] ?? 0) + (typeCounts["pipeline"] ?? 0) },
{ label: t.projectOverview.data, color: "var(--color-node-table)", count: (typeCounts["table"] ?? 0) + (typeCounts["endpoint"] ?? 0) + (typeCounts["schema"] ?? 0) },
{ label: t.projectOverview.domain, color: "var(--color-node-concept)", count: (typeCounts["domain"] ?? 0) + (typeCounts["flow"] ?? 0) + (typeCounts["step"] ?? 0) },
];
const hasNonCodeNodes = categoryBreakdown.some((c) => c.label !== t.projectOverview.code && c.count > 0);
return (
<div className="h-full w-full overflow-auto p-5 animate-fade-slide-in">
{/* Project name */}
<h2 className="font-heading text-2xl text-text-primary mb-1">{project.name}</h2>
<p className="text-sm text-text-secondary leading-relaxed mb-6">{project.description}</p>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-accent">{nodes.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">{t.projectOverview.nodes}</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-accent">{edges.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">{t.projectOverview.edges}</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-accent">{layers.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">{t.projectOverview.layers}</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-accent">{Object.keys(typeCounts).length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">{t.projectOverview.types}</div>
</div>
</div>
{/* File Types breakdown */}
{hasNonCodeNodes && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">{t.projectOverview.fileTypes}</h3>
<div className="space-y-1.5">
{categoryBreakdown.filter((c) => c.count > 0).map((cat) => (
<div key={cat.label} className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
<span className="text-xs text-text-secondary flex-1">{cat.label}</span>
<span className="text-xs font-mono text-text-muted">{cat.count}</span>
</div>
))}
</div>
</div>
)}
{/* Languages */}
{project.languages.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">{t.projectOverview.languages}</h3>
<div className="flex flex-wrap gap-1.5">
{project.languages.map((lang) => (
<span key={lang} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
{lang}
</span>
))}
</div>
</div>
)}
{/* Frameworks */}
{project.frameworks.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">{t.projectOverview.frameworks}</h3>
<div className="flex flex-wrap gap-1.5">
{project.frameworks.map((fw) => (
<span key={fw} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
{fw}
</span>
))}
</div>
</div>
)}
{/* Node Type Breakdown */}
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">{t.projectOverview.nodeTypeDistribution}</h3>
<div className="space-y-2">
{Object.entries(typeCounts)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => {
const percentage = ((count / nodes.length) * 100).toFixed(0);
return (
<div key={type}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-secondary capitalize">{type}</span>
<span className="text-text-muted font-mono">{count} ({percentage}%)</span>
</div>
<div className="w-full h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-accent/50 rounded-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
{/* Complexity Breakdown */}
{Object.values(complexityCounts).some((c) => c > 0) && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">{t.projectOverview.complexityDistribution}</h3>
<div className="grid grid-cols-3 gap-2">
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-green-400">{complexityCounts.simple}</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mt-0.5">{t.projectOverview.simple}</div>
</div>
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-yellow-400">{complexityCounts.moderate}</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mt-0.5">{t.projectOverview.moderate}</div>
</div>
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-red-400">{complexityCounts.complex}</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mt-0.5">{t.projectOverview.complex}</div>
</div>
</div>
</div>
)}
{/* Top Connected Nodes */}
{topNodes.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">{t.projectOverview.mostConnectedNodes}</h3>
<div className="space-y-2">
{topNodes.map((node, idx) => (
<div
key={node.id}
className="flex items-center gap-2 text-xs bg-elevated rounded-lg p-2 border border-border-subtle"
>
<div className="w-5 h-5 shrink-0 rounded-full bg-accent/20 flex items-center justify-center text-[10px] font-bold text-accent">
{idx + 1}
</div>
<span className="flex-1 text-text-primary truncate">{node.name}</span>
<span className="text-text-muted font-mono shrink-0">{node.count}</span>
</div>
))}
</div>
</div>
)}
{/* Average Connections */}
<div className="mb-5 bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="flex items-center justify-between">
<span className="text-xs text-text-secondary">{t.projectOverview.avgConnectionsPerNode}</span>
<span className="text-lg font-mono font-medium text-accent">{avgConnections}</span>
</div>
</div>
{/* Analyzed at */}
<div className="text-[11px] text-text-muted mb-6">
{t.common.analyzed}: {new Date(project.analyzedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
</div>
{/* Start Tour button */}
{hasTour && (
<button
onClick={startTour}
className="w-full bg-accent/10 border border-accent/30 text-accent text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-accent/20 transition-all duration-200"
>
{t.common.startGuidedTour}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,328 @@
import { useMemo, useState } from "react";
import { useDashboardStore } from "../store";
type AnswerMode = "search" | "llm";
interface MatchReason {
type: string;
field?: string;
message: string;
score: number;
}
interface RagHit {
chunkId: string;
nodeId: string;
docTitle: string;
docPath: string;
sectionTitle: string;
chunkType: string;
layer: string;
score: number;
scores: {
exact: number;
fuzzy: number;
semantic: number;
titleBoost: number;
sectionBoost: number;
directoryBoost: number;
chunkTypeBoost: number;
};
snippet: string;
evidenceContent?: string;
reasons: MatchReason[];
}
interface LlmSettings {
enabled: boolean;
provider: "auto" | "openai" | "routin-plan";
endpoint: string;
model: string;
apiKey: string;
}
interface EvidenceDecision {
allowed: boolean;
confidence: "high" | "medium" | "low" | "none";
reason: string;
}
interface RequestStatus {
stage?: string;
startedAt?: string;
endpoint?: string;
protocol?: string;
elapsedMs?: number;
rawShape?: string[];
}
interface RagResponse {
ok: boolean;
mode?: string;
query?: unknown;
decision?: EvidenceDecision;
answer?: string;
hits?: RagHit[];
requestStatus?: RequestStatus;
error?: {
message?: string;
name?: string;
status?: number;
};
}
const SETTINGS_KEY = "ua-rag-llm-settings-v2";
function loadSettings(): LlmSettings {
if (typeof window === "undefined") return { enabled: false, provider: "auto", endpoint: "http://localhost:11434/v1", model: "qwen2.5:7b", apiKey: "" };
try {
const raw = window.localStorage.getItem(SETTINGS_KEY);
if (raw) return { enabled: false, provider: "auto", endpoint: "http://localhost:11434/v1", model: "qwen2.5:7b", apiKey: "", ...JSON.parse(raw) };
} catch {
}
return { enabled: false, provider: "auto", endpoint: "http://localhost:11434/v1", model: "qwen2.5:7b", apiKey: "" };
}
function saveSettings(settings: LlmSettings): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function stageLabel(stage?: string): string {
switch (stage) {
case "search": return "本地检索中";
case "evidence_rejected": return "证据不足,未调用模型";
case "local_answer": return "本地回答";
case "llm_done": return "模型回答完成";
case "llm_failed": return "模型调用失败";
default: return stage || "待请求";
}
}
async function postJson<T>(url: string, payload: unknown): Promise<{ status: number; data: T }> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const text = await response.text();
let data: T;
try {
data = text ? JSON.parse(text) as T : {} as T;
} catch {
data = { ok: false, error: { message: text || "响应不是 JSON" } } as T;
}
return { status: response.status, data };
}
export default function RagAssistant({ onClose }: { onClose: () => void }) {
const graph = useDashboardStore((s) => s.graph);
const navigateToNodeInLayer = useDashboardStore((s) => s.navigateToNodeInLayer);
const [question, setQuestion] = useState("");
const [submittedQuestion, setSubmittedQuestion] = useState("");
const [mode, setMode] = useState<AnswerMode>("search");
const [settings, setSettings] = useState<LlmSettings>(loadSettings);
const [answer, setAnswer] = useState("");
const [hits, setHits] = useState<RagHit[]>([]);
const [decision, setDecision] = useState<EvidenceDecision | null>(null);
const [requestStatus, setRequestStatus] = useState<RequestStatus>({});
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [selectedHit, setSelectedHit] = useState<RagHit | null>(null);
const nodeCount = graph?.nodes.length ?? 0;
const docCount = useMemo(() => graph?.nodes.filter((node) => node.id.startsWith("doc:")).length ?? 0, [graph]);
const updateSettings = (patch: Partial<LlmSettings>) => {
const next = { ...settings, ...patch };
setSettings(next);
saveSettings(next);
};
const submit = async () => {
const trimmed = question.trim();
setSubmittedQuestion(trimmed);
setError(null);
setAnswer("");
setHits([]);
setSelectedHit(null);
setDecision(null);
setRequestStatus({ stage: mode === "llm" ? "search" : "search", startedAt: new Date().toISOString() });
if (!trimmed) {
setAnswer("请输入要检索或提问的内容。");
return;
}
setLoading(true);
try {
const endpoint = mode === "llm" ? "/api/rag/answer" : "/api/rag/search";
const { status, data } = await postJson<RagResponse>(endpoint, {
query: trimmed,
topK: 16,
llm: settings,
});
setRequestStatus(data.requestStatus || { stage: mode === "llm" ? "llm_done" : "local_answer" });
setDecision(data.decision || null);
setHits(data.hits || []);
setAnswer(data.answer || "暂无该需求描述。");
if (!data.ok) {
const message = data.error?.message || `请求失败HTTP ${status}`;
setError(message);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRequestStatus({ stage: "llm_failed" });
setAnswer("请求失败,未能获取检索结果。");
} finally {
setLoading(false);
}
};
const testModel = async () => {
setTesting(true);
setError(null);
setRequestStatus({ stage: "模型连接测试中", startedAt: new Date().toISOString() });
try {
const { status, data } = await postJson<RagResponse & { endpoint?: string; elapsedMs?: number }>("/api/llm/test", settings);
if (!data.ok) {
setRequestStatus({ stage: "llm_failed", endpoint: data.endpoint, elapsedMs: data.elapsedMs });
setError(data.error?.message || `模型连接测试失败HTTP ${status}`);
} else {
setRequestStatus({ stage: "llm_done", endpoint: data.endpoint, elapsedMs: data.elapsedMs });
setAnswer(`模型连接成功。\nEndpoint${data.endpoint || settings.endpoint}\n耗时${data.elapsedMs ?? "?"} ms\n返回${data.answer || ""}`);
}
} catch (err) {
setRequestStatus({ stage: "llm_failed" });
setError(err instanceof Error ? err.message : String(err));
} finally {
setTesting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 backdrop-blur-sm p-4 sm:p-6" onMouseDown={onClose}>
<div className="w-[calc(100vw-32px)] max-w-[1180px] h-[calc(100vh-64px)] max-h-[860px] rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden flex flex-col" onMouseDown={(event) => event.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle bg-elevated/40">
<div>
<h2 className="font-heading text-lg text-text-primary"> RAG / </h2>
<p className="text-xs text-text-muted mt-1"></p>
</div>
<button type="button" onClick={onClose} className="text-text-muted hover:text-accent transition-colors text-xl leading-none">×</button>
</div>
<div className="p-5 border-b border-border-subtle space-y-3">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => { setMode("search"); setShowSettings(false); }} className={`px-3 py-1.5 rounded-md border text-xs font-semibold ${mode === "search" && !showSettings ? "border-accent/50 bg-accent/20 text-accent" : "border-border-subtle text-text-muted hover:text-text-primary"}`}></button>
<button type="button" onClick={() => { setMode("llm"); setShowSettings(false); }} className={`px-3 py-1.5 rounded-md border text-xs font-semibold ${mode === "llm" && !showSettings ? "border-accent/50 bg-accent/20 text-accent" : "border-border-subtle text-text-muted hover:text-text-primary"}`}></button>
<button type="button" onClick={() => setShowSettings((v) => !v)} className={`px-3 py-1.5 rounded-md border text-xs font-semibold ${showSettings ? "border-accent/50 bg-accent/20 text-accent" : "border-border-subtle text-text-muted hover:text-text-primary"}`}></button>
<div className="text-[11px] text-text-muted">{nodeCount}{docCount}</div>
</div>
{showSettings && (
<div className="grid grid-cols-1 md:grid-cols-[120px_160px_1fr_160px_100px] gap-2 border border-border-subtle rounded-lg p-3 bg-elevated/30">
<label className="flex items-center gap-2 text-xs text-text-secondary">
<input type="checkbox" checked={settings.enabled} onChange={(e) => updateSettings({ enabled: e.target.checked })} />
</label>
<select value={settings.provider} onChange={(e) => updateSettings({ provider: e.target.value as LlmSettings["provider"] })} className="bg-surface text-text-primary text-xs rounded px-2 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50">
<option value="auto"></option>
<option value="openai">OpenAI兼容</option>
<option value="routin-plan">RoutIn Plan</option>
</select>
<input value={settings.endpoint} onChange={(e) => updateSettings({ endpoint: e.target.value })} placeholder="Base URLRoutIn Plan 填 https://api.routin.ai/plan/v1" className="bg-surface text-text-primary text-xs rounded px-2 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50" />
<input value={settings.model} onChange={(e) => updateSettings({ model: e.target.value })} placeholder="model" className="bg-surface text-text-primary text-xs rounded px-2 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50" />
<button type="button" onClick={() => void testModel()} disabled={testing} className="px-2 py-1.5 rounded border border-accent/30 text-xs text-accent disabled:opacity-60">{testing ? "测试中" : "测试连接"}</button>
<div className="hidden md:block" />
<div className="hidden md:block text-[10px] text-text-muted pt-1">plan- key /messages</div>
<input type="password" value={settings.apiKey} onChange={(e) => updateSettings({ apiKey: e.target.value })} placeholder="API Key通过本地后端代理发送不从浏览器直连模型" className="md:col-span-3 bg-surface text-text-primary text-xs rounded px-2 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50" />
</div>
)}
<div className="flex gap-2">
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") void submit();
}}
placeholder="例如:黑名单是什么?回评是什么?对外 API 契约(草案);风险与反欺诈有哪些接口?"
className="flex-1 min-h-[76px] resize-none bg-elevated text-text-primary text-sm rounded-lg px-3 py-2 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
/>
<button type="button" onClick={() => void submit()} disabled={loading} className="px-4 py-2 rounded-lg bg-accent/20 text-accent hover:text-accent-bright border border-accent/30 text-sm font-semibold transition-colors disabled:opacity-60">
{loading ? "处理中" : mode === "llm" ? "问答" : "检索"}
</button>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] text-text-muted">
<span>Ctrl + Enter </span>
<span><span className="text-accent">{stageLabel(requestStatus.stage)}</span></span>
{requestStatus.endpoint && <span>Endpoint{requestStatus.endpoint}</span>}
{requestStatus.protocol && <span>{requestStatus.protocol}</span>}
{typeof requestStatus.elapsedMs === "number" && <span>{requestStatus.elapsedMs} ms</span>}
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto p-5 grid grid-cols-1 lg:grid-cols-[1fr_440px] gap-5">
<section className="min-w-0 space-y-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider"> / </h3>
{submittedQuestion && decision && <span className={`text-[11px] ${decision.allowed ? "text-accent" : "text-text-muted"}`}>{decision.reason}</span>}
</div>
{error && <div className="text-xs text-red-300 border border-red-400/30 bg-red-500/10 rounded p-2">{error}</div>}
<pre className="whitespace-pre-wrap text-sm leading-relaxed text-text-secondary bg-elevated/50 border border-border-subtle rounded-lg p-4 font-sans min-h-[260px]">
{submittedQuestion || answer ? answer || "等待后端返回。" : "输入问题后点击“检索”或“问答”。"}
</pre>
{selectedHit && (
<div className="border border-border-subtle rounded-lg bg-elevated/40 p-3">
<div className="text-xs text-accent mb-1"></div>
<div className="text-sm text-text-primary">{selectedHit.docTitle} / {selectedHit.sectionTitle}</div>
<div className="text-[11px] text-text-muted mt-1">{selectedHit.docPath}</div>
<pre className="mt-2 max-h-[260px] overflow-auto whitespace-pre-wrap text-xs leading-relaxed text-text-secondary font-sans">{selectedHit.evidenceContent || selectedHit.snippet}</pre>
</div>
)}
</section>
<section className="min-w-0">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2"></h3>
<div className="space-y-2">
{hits.length === 0 && submittedQuestion && (
<div className="text-sm text-text-muted border border-border-subtle rounded-lg p-3"></div>
)}
{hits.slice(0, 12).map((hit) => (
<div key={hit.chunkId} className="border border-border-subtle bg-elevated/40 hover:border-accent/40 rounded-lg p-3 transition-colors">
<button
type="button"
onClick={() => navigateToNodeInLayer(hit.nodeId)}
className="w-full text-left"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm text-text-primary truncate">{hit.docTitle}</div>
<div className="text-[11px] text-accent mt-1 truncate">{hit.sectionTitle}</div>
</div>
<div className="text-[11px] text-accent shrink-0">{hit.score.toFixed(1)}</div>
</div>
<div className="text-[11px] text-text-muted mt-1 truncate">{hit.docPath}</div>
</button>
<button type="button" onClick={() => setSelectedHit(hit)} className="mt-2 text-left text-xs text-text-secondary line-clamp-4 hover:text-text-primary">{hit.snippet}</button>
<div className="mt-2 grid grid-cols-4 gap-1 text-[10px] text-text-muted">
<span>exact {hit.scores.exact.toFixed(0)}</span>
<span>fuzzy {hit.scores.fuzzy.toFixed(0)}</span>
<span>semantic {hit.scores.semantic.toFixed(0)}</span>
<span>boost {(hit.scores.titleBoost + hit.scores.sectionBoost + hit.scores.directoryBoost + hit.scores.chunkTypeBoost).toFixed(0)}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{hit.reasons.slice(0, 5).map((reason, index) => (
<span key={`${hit.chunkId}-${index}`} className="text-[10px] text-text-muted border border-border-subtle rounded px-1.5 py-0.5">
{reason.message} +{reason.score.toFixed(0)}
</span>
))}
</div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDashboardStore } from "../store";
import { useI18n } from "../contexts/I18nContext";
const typeBadgeColors: Record<string, string> = {
file: "text-node-file border border-node-file/30 bg-node-file/10",
function: "text-node-function border border-node-function/30 bg-node-function/10",
class: "text-node-class border border-node-class/30 bg-node-class/10",
module: "text-node-module border border-node-module/30 bg-node-module/10",
concept: "text-node-concept border border-node-concept/30 bg-node-concept/10",
config: "text-node-config border border-node-config/30 bg-node-config/10",
document: "text-node-document border border-node-document/30 bg-node-document/10",
service: "text-node-service border border-node-service/30 bg-node-service/10",
table: "text-node-table border border-node-table/30 bg-node-table/10",
endpoint: "text-node-endpoint border border-node-endpoint/30 bg-node-endpoint/10",
pipeline: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10",
schema: "text-node-schema border border-node-schema/30 bg-node-schema/10",
resource: "text-node-resource border border-node-resource/30 bg-node-resource/10",
domain: "text-node-concept border border-node-concept/30 bg-node-concept/10",
flow: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10",
step: "text-node-function border border-node-function/30 bg-node-function/10",
article: "text-node-article border border-node-article/30 bg-node-article/10",
entity: "text-node-entity border border-node-entity/30 bg-node-entity/10",
topic: "text-node-topic border border-node-topic/30 bg-node-topic/10",
claim: "text-node-claim border border-node-claim/30 bg-node-claim/10",
source: "text-node-source border border-node-source/30 bg-node-source/10",
};
export default function SearchBar() {
const searchQuery = useDashboardStore((s) => s.searchQuery);
const searchResults = useDashboardStore((s) => s.searchResults);
const graph = useDashboardStore((s) => s.graph);
const setSearchQuery = useDashboardStore((s) => s.setSearchQuery);
const navigateToNodeInLayer = useDashboardStore((s) => s.navigateToNodeInLayer);
const searchMode = useDashboardStore((s) => s.searchMode);
const setSearchMode = useDashboardStore((s) => s.setSearchMode);
const { t } = useI18n();
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Build a lookup map for node details
const nodeMap = useMemo(
() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])),
[graph],
);
const topResults = searchResults.slice(0, 5);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setDropdownOpen(true);
},
[setSearchQuery],
);
const handleResultClick = useCallback(
(nodeId: string) => {
navigateToNodeInLayer(nodeId);
setDropdownOpen(false);
},
[navigateToNodeInLayer],
);
// Close dropdown on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setDropdownOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0;
return (
<div ref={containerRef} className="relative z-30">
<div className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-surface border-b border-border-subtle">
<svg
className="w-4 h-4 text-text-muted shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={handleInputChange}
onFocus={() => setDropdownOpen(true)}
placeholder={t.search.placeholder}
data-testid="search-input"
className="flex-1 min-w-0 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
/>
<div className="flex items-center gap-1 bg-elevated rounded-lg p-0.5 shrink-0">
<button
onClick={() => setSearchMode("fuzzy")}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
searchMode === "fuzzy"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.search.fuzzy}
</button>
<button
onClick={() => setSearchMode("semantic")}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
searchMode === "semantic"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
{t.search.semantic}
</button>
</div>
{searchQuery.trim() && (
<span className="hidden sm:inline text-xs text-text-muted shrink-0">
{searchResults.length} {t.search.result}{searchResults.length !== 1 ? "s" : ""}{" "}
<span className="text-text-muted">({searchMode})</span>
</span>
)}
</div>
{/* Dropdown results */}
{showDropdown && (
<div className="absolute left-4 right-4 top-full mt-0.5 glass rounded-lg shadow-xl overflow-hidden">
{topResults.map((result) => {
const node = nodeMap.get(result.nodeId);
if (!node) return null;
const relevance = Math.round((1 - result.score) * 100);
const badgeColor = typeBadgeColors[node.type] ?? typeBadgeColors.file;
return (
<button
key={result.nodeId}
type="button"
onClick={() => handleResultClick(result.nodeId)}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-elevated transition-colors text-left"
>
{/* Type badge */}
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${badgeColor} shrink-0`}
>
{node.type}
</span>
{/* Node name */}
<span className="text-sm text-text-primary truncate flex-1">
{node.name}
</span>
{/* Relevance bar */}
<div className="flex items-center gap-1.5 shrink-0">
<div className="w-16 h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${relevance}%` }}
/>
</div>
<span className="text-[10px] text-text-muted w-7 text-right">
{relevance}%
</span>
</div>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { Node, NodeProps } from "@xyflow/react";
import { useDashboardStore } from "../store";
export interface StepNodeData extends Record<string, unknown> {
label: string;
summary: string;
filePath?: string;
stepId: string;
order: number;
}
export type StepFlowNode = Node<StepNodeData, "step-node">;
function StepNode({ data }: NodeProps<StepFlowNode>) {
const selectNode = useDashboardStore((s) => s.selectNode);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const isSelected = selectedNodeId === data.stepId;
return (
<div
className={`rounded-lg border px-3 py-2.5 min-w-[180px] max-w-[240px] cursor-pointer transition-all ${
isSelected
? "border-accent bg-accent/10"
: "border-border-subtle bg-elevated hover:border-accent/40"
}`}
onClick={() => selectNode(data.stepId)}
>
<Handle type="target" position={Position.Left} className="!bg-text-muted/40 !w-1.5 !h-1.5" />
<Handle type="source" position={Position.Right} className="!bg-text-muted/40 !w-1.5 !h-1.5" />
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[9px] font-mono text-accent/60 shrink-0">
{data.order}
</span>
<span className="text-[11px] font-medium text-text-primary truncate">
{data.label}
</span>
</div>
<div className="text-[10px] text-text-secondary line-clamp-2">
{data.summary}
</div>
{data.filePath && (
<div className="text-[9px] font-mono text-text-muted mt-1 truncate">
{data.filePath}
</div>
)}
</div>
);
}
export default memo(StepNode);

View File

@@ -0,0 +1,180 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTheme, PRESETS } from "../themes/index.ts";
import type { HeadingFont } from "../themes/index.ts";
import { useI18n } from "../contexts/I18nContext";
export function ThemePicker() {
const { config, preset, setPreset, setAccent, setHeadingFont } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { t } = useI18n();
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
const handlePreset = useCallback(
(id: string) => {
setPreset(id as Parameters<typeof setPreset>[0]);
},
[setPreset],
);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-text-secondary hover:text-text-primary transition-colors"
title={t.themePicker.changeTheme}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a7 7 0 0 0 0 14 4 4 0 0 1 0 8 10 10 0 0 0 0-20z" />
<circle cx="8" cy="10" r="1.5" fill="currentColor" />
<circle cx="12" cy="7" r="1.5" fill="currentColor" />
<circle cx="16" cy="10" r="1.5" fill="currentColor" />
</svg>
<span className="hidden sm:inline">{t.common.theme}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-64 rounded-lg glass-heavy shadow-xl z-50 p-3 space-y-3">
{/* Presets */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
{t.themePicker.theme}
</div>
<div className="space-y-1">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => handlePreset(p.id)}
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded text-xs transition-colors ${
p.id === config.presetId
? "bg-accent/15 text-accent"
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
}`}
>
{/* Color preview dots */}
<div className="flex gap-1">
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{ backgroundColor: p.colors.root }}
/>
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{ backgroundColor: p.colors.surface }}
/>
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{
backgroundColor:
p.accentSwatches.find((s) => s.id === p.defaultAccentId)?.accent ??
p.accentSwatches[0].accent,
}}
/>
</div>
<span>{p.name}</span>
{p.id === config.presetId && (
<svg
className="ml-auto w-3.5 h-3.5 text-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
</div>
{/* Accent swatches */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
{t.themePicker.accentColor}
</div>
<div className="flex gap-2 flex-wrap">
{preset.accentSwatches.map((swatch) => (
<button
key={swatch.id}
onClick={() => setAccent(swatch.id)}
className={`w-6 h-6 rounded-full transition-transform hover:scale-110 ${
swatch.id === config.accentId
? "ring-2 ring-text-primary ring-offset-1 ring-offset-root"
: ""
}`}
style={{ backgroundColor: swatch.accent }}
title={swatch.name}
/>
))}
</div>
</div>
{/* Heading font */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
{t.themePicker.headingFont}
</div>
<div className="flex gap-1">
{([
{ id: "serif" as HeadingFont, label: t.themePicker.serif, sample: "Aa" },
{ id: "sans" as HeadingFont, label: t.themePicker.sans, sample: "Aa" },
{ id: "mono" as HeadingFont, label: t.themePicker.mono, sample: "Aa" },
]).map((opt) => (
<button
key={opt.id}
onClick={() => setHeadingFont(opt.id)}
className={`flex-1 px-2 py-1.5 rounded text-xs transition-colors ${
(config.headingFont ?? "serif") === opt.id
? "bg-accent/15 text-accent"
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
}`}
style={{
fontFamily:
opt.id === "serif"
? "var(--font-serif)"
: opt.id === "mono"
? "var(--font-mono)"
: "var(--font-sans)",
}}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
interface TokenGateProps {
onTokenValid: (token: string) => void;
}
export default function TokenGate({ onTokenValid }: TokenGateProps) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = input.trim();
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/knowledge-graph.json?token=${encodeURIComponent(token)}`);
if (res.ok) {
onTokenValid(token);
} else if (res.status === 403) {
setError("Invalid token. Please check and try again.");
} else {
setError(`Unexpected response (${res.status}). Is the dashboard server running?`);
}
} catch (err) {
setError(
`Could not reach the server: ${err instanceof Error ? err.message : String(err)}`
);
} finally {
setLoading(false);
}
};
return (
<div className="h-screen w-screen flex items-center justify-center bg-root noise-overlay">
<div className="w-full max-w-md px-8 py-10 bg-surface border border-border-subtle rounded-lg shadow-2xl">
{/* Heading */}
<h1 className="font-heading text-2xl text-text-primary tracking-wide text-center mb-2">
Access Token Required
</h1>
<p className="text-text-muted text-sm text-center mb-8">
Paste the access token from your terminal. Look for the{" "}
<span role="img" aria-label="key">&#x1F511;</span> line.
</p>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
placeholder="Paste token here..."
autoFocus
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded text-text-primary placeholder:text-text-muted/50 font-mono text-sm focus:outline-none focus:border-accent transition-colors"
/>
{error && (
<p className="text-red-400 text-sm">{error}</p>
)}
<button
type="submit"
disabled={loading || !input.trim()}
className="w-full py-3 bg-accent text-root font-semibold rounded transition-all hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? "Validating..." : "Continue"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { useState, useCallback } from "react";
import type { GraphIssue } from "@understand-anything/core/schema";
interface WarningBannerProps {
issues: GraphIssue[];
}
function buildCopyText(issues: GraphIssue[]): string {
const hasFatal = issues.some((i) => i.level === "fatal");
// Fatal issues are dashboard rendering bugs (e.g. ELK layout failures), not
// LLM generation errors — route the user to file a bug report instead of
// asking their agent to "fix" the knowledge-graph.json.
const lines = hasFatal
? [
"Some of these issues look like dashboard rendering bugs.",
"Please file an issue at github.com/Lum1104/Understand-Anything/issues with the text below.",
"",
]
: [
"The following issues were found in your knowledge-graph.json.",
"These are LLM generation errors — not a system bug.",
"You can ask your agent to fix these specific issues in the knowledge-graph.json file:",
"",
];
// Show fatal first (most actionable for bug reports), then dropped, then auto-corrected.
const sorted = [...issues].sort((a, b) => {
const order: Record<string, number> = { fatal: 0, dropped: 1, "auto-corrected": 2 };
return (order[a.level] ?? 3) - (order[b.level] ?? 3);
});
for (const issue of sorted) {
const label =
issue.level === "auto-corrected"
? "Auto-corrected"
: issue.level === "dropped"
? "Dropped"
: "Fatal";
lines.push(`[${label}] ${issue.message}`);
}
return lines.join("\n");
}
export default function WarningBanner({ issues }: WarningBannerProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const fatal = issues.filter((i) => i.level === "fatal");
const autoCorrected = issues.filter((i) => i.level === "auto-corrected");
const dropped = issues.filter((i) => i.level === "dropped");
const hasFatal = fatal.length > 0;
// Build summary text — only mention counts > 0
const parts: string[] = [];
if (fatal.length > 0) {
parts.push(`${fatal.length} fatal error${fatal.length !== 1 ? "s" : ""}`);
}
if (autoCorrected.length > 0) {
parts.push(`${autoCorrected.length} auto-correction${autoCorrected.length !== 1 ? "s" : ""}`);
}
if (dropped.length > 0) {
parts.push(`${dropped.length} dropped item${dropped.length !== 1 ? "s" : ""}`);
}
const summary = hasFatal
? `Dashboard hit ${parts.join(", ")}`
: `Knowledge graph loaded with ${parts.join(" and ")}`;
const handleCopy = useCallback(async () => {
const text = buildCopyText(issues);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
console.warn("Clipboard write failed — copy text manually from the expanded issue list");
}
}, [issues]);
if (issues.length === 0) return null;
// Fatal issues escalate the banner from amber (warning) to red (error).
const containerClasses = hasFatal
? "bg-red-900/25 border-b border-red-700 text-red-200 text-sm"
: "bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm";
const hoverClasses = hasFatal
? "hover:bg-red-900/15"
: "hover:bg-amber-900/10";
const iconClasses = hasFatal ? "text-red-400" : "text-amber-400";
const hintClasses = hasFatal ? "text-red-400/60" : "text-amber-400/60";
const dividerClasses = hasFatal ? "border-red-700/50" : "border-amber-700/50";
const footerTextClasses = hasFatal ? "text-red-200/70" : "text-amber-200/60";
const buttonClasses = hasFatal
? "bg-red-800/40 text-red-200 hover:bg-red-800/60"
: "bg-amber-800/40 text-amber-200 hover:bg-amber-800/60";
const footerCopy = hasFatal
? "Copy these issues and file a bug report on GitHub"
: "Copy these issues and ask your agent to fix them in knowledge-graph.json";
return (
<div className={containerClasses}>
{/* Collapsed summary row */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
className={`w-full flex items-center gap-2 px-5 py-3 text-left transition-colors ${hoverClasses}`}
>
{/* Chevron icon */}
<svg
className={`w-4 h-4 shrink-0 ${iconClasses} transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
{/* Warning icon */}
<svg
className={`w-4 h-4 shrink-0 ${iconClasses}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span className="flex-1">{summary}</span>
<span className={`text-xs shrink-0 ${hintClasses}`}>
{expanded ? "click to collapse" : "click to expand"}
</span>
</button>
{/* Expanded detail panel */}
{expanded && (
<div className="px-5 pb-4">
{/* Issue list */}
<div className="space-y-1 mb-3">
{/* Fatal issues — top of list, red, most prominent */}
{fatal.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-400 mb-1">
Fatal ({fatal.length})
</h4>
{fatal.map((issue, i) => (
<div
key={`ft-${i}`}
className="flex items-start gap-2 py-0.5 pl-2 text-red-200"
>
<span className="text-red-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
{/* Auto-corrected issues */}
{autoCorrected.length > 0 && (
<div className={fatal.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-amber-400 mb-1">
Auto-corrected ({autoCorrected.length})
</h4>
{autoCorrected.map((issue, i) => (
<div key={`ac-${i}`} className="flex items-start gap-2 py-0.5 pl-2 text-amber-200/80">
<span className="text-amber-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
{/* Dropped issues */}
{dropped.length > 0 && (
<div className={fatal.length > 0 || autoCorrected.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-400 mb-1">
Dropped ({dropped.length})
</h4>
{dropped.map((issue, i) => (
<div key={`dr-${i}`} className="flex items-start gap-2 py-0.5 pl-2 text-orange-300/80">
<span className="text-orange-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
</div>
{/* Footer with copy button and actionable message */}
<div className={`flex items-center justify-between pt-2 border-t ${dividerClasses}`}>
<p className={`text-xs ${footerTextClasses}`}>{footerCopy}</p>
<button
type="button"
onClick={handleCopy}
className={`flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium transition-colors shrink-0 ml-4 ${buttonClasses}`}
>
{copied ? (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
Copy Issues
</>
)}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { getLocale, resolveLocaleKey, type Locale, type LocaleKey } from "../locales";
interface I18nContextValue {
locale: Locale;
localeKey: LocaleKey;
t: Locale;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n must be used within an I18nProvider");
}
return ctx;
}
export function I18nProvider({
language,
children,
}: {
language?: string;
children: ReactNode;
}) {
const localeKey = useMemo(() => resolveLocaleKey(language), [language]);
const locale = useMemo(() => getLocale(localeKey), [localeKey]);
const value = useMemo(
() => ({
locale,
localeKey,
t: locale,
}),
[locale, localeKey]
);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
const DEFAULT_BREAKPOINT = 768;
export function useIsMobile(breakpoint: number = DEFAULT_BREAKPOINT): boolean {
const query = `(max-width: ${breakpoint - 1}px)`;
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mql.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return isMobile;
}

View File

@@ -0,0 +1,71 @@
import { useEffect } from "react";
export interface KeyboardShortcut {
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
description: string;
action: () => void;
category: string;
}
export function useKeyboardShortcuts(
shortcuts: KeyboardShortcut[],
enabled = true
) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Prevent shortcuts from firing when typing in input fields
const target = event.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || target.isContentEditable) {
if (event.key !== 'Escape') return;
}
for (const shortcut of shortcuts) {
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatches = shortcut.ctrlKey ? event.ctrlKey : !event.ctrlKey;
const shiftMatches = shortcut.shiftKey ? event.shiftKey : !event.shiftKey;
const altMatches = shortcut.altKey ? event.altKey : !event.altKey;
const metaMatches = shortcut.metaKey ? event.metaKey : !event.metaKey;
if (keyMatches && ctrlMatches && shiftMatches && altMatches && metaMatches) {
// Prevent default for shortcuts that might conflict with browser
if (event.ctrlKey || event.metaKey || event.altKey) {
event.preventDefault();
}
shortcut.action();
break;
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [shortcuts, enabled]);
}
export function formatShortcutKey(shortcut: KeyboardShortcut): string {
const keys: string[] = [];
// Use userAgentData with fallback to navigator.platform
const isMac = (navigator as Navigator & { userAgentData?: { platform: string } }).userAgentData?.platform
? (navigator as Navigator & { userAgentData: { platform: string } }).userAgentData.platform === 'macOS'
: navigator.platform.includes("Mac");
if (shortcut.ctrlKey || shortcut.metaKey) {
keys.push(isMac ? "⌘" : "Ctrl");
}
// Don't show ⇧ for keys that inherently require Shift (e.g. ?, !, @)
const isShiftedPunctuation = shortcut.key.length === 1 && /[^a-zA-Z0-9]/.test(shortcut.key);
if (shortcut.shiftKey && !isShiftedPunctuation) keys.push("⇧");
if (shortcut.altKey) keys.push(isMac ? "⌥" : "Alt");
keys.push(isShiftedPunctuation ? shortcut.key : shortcut.key.toUpperCase());
return keys.join(" + ");
}

View File

@@ -0,0 +1,272 @@
@import "tailwindcss";
@source "./**/*.{ts,tsx,js,jsx,html}";
@source "../index.html";
@theme {
/* Base */
--color-root: #0a0a0a;
--color-surface: #111111;
--color-elevated: #1a1a1a;
--color-panel: #141414;
/* Accent */
--color-accent: #d4a574;
--color-accent-dim: #c9a96e;
--color-accent-bright: #e8c49a;
/* Text */
--color-text-primary: #f5f0eb;
--color-text-secondary: #a39787;
--color-text-muted: #6b5f53;
/* Borders */
--color-border-subtle: rgba(212, 165, 116, 0.12);
--color-border-medium: rgba(212, 165, 116, 0.25);
/* Node types */
--color-node-file: #4a7c9b;
--color-node-function: #5a9e6f;
--color-node-class: #8b6fb0;
--color-node-module: #c9a06c;
--color-node-concept: #b07a8a;
--color-node-config: #5eead4;
--color-node-document: #7dd3fc;
--color-node-service: #a78bfa;
--color-node-table: #6ee7b7;
--color-node-endpoint: #fdba74;
--color-node-pipeline: #fda4af;
--color-node-schema: #fcd34d;
--color-node-resource: #a5b4fc;
/* Knowledge node types */
--color-node-article: #d4a574;
--color-node-entity: #7ba4c9;
--color-node-topic: #c9b06c;
--color-node-claim: #6fb07a;
--color-node-source: #8a8a8a;
/* Diff */
--color-diff-changed: #e05252;
--color-diff-affected: #d4a030;
--color-diff-changed-dim: rgba(224, 82, 82, 0.25);
--color-diff-affected-dim: rgba(212, 160, 48, 0.25);
/* Glass */
--glass-bg: rgba(20, 20, 20, 0.8);
--glass-bg-heavy: rgba(20, 20, 20, 0.95);
--glass-border: rgba(212, 165, 116, 0.1);
--glass-border-heavy: rgba(212, 165, 116, 0.15);
/* Scrollbar */
--scrollbar-thumb: rgba(212, 165, 116, 0.2);
--scrollbar-thumb-hover: rgba(212, 165, 116, 0.35);
/* Glow */
--glow-accent: rgba(212, 165, 116, 0.15);
--glow-accent-strong: rgba(212, 165, 116, 0.4);
--glow-accent-pulse: rgba(212, 165, 116, 0.6);
/* Edges */
--color-edge: rgba(212, 165, 116, 0.3);
--color-edge-dim: rgba(212, 165, 116, 0.08);
--color-edge-dot: rgba(212, 165, 116, 0.15);
/* Accent overlays */
--color-accent-overlay-bg: rgba(212, 165, 116, 0.05);
--color-accent-overlay-border: rgba(212, 165, 116, 0.25);
/* Kbd */
--kbd-bg: rgba(212, 165, 116, 0.1);
/* Typography */
--font-serif: 'DM Serif Display', Georgia, serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: 'Inter', system-ui, sans-serif;
--font-heading: var(--font-serif);
}
.font-heading { font-family: var(--font-heading); }
/* Base styles */
html {
transition: background-color 0.2s ease, color 0.2s ease;
}
body {
font-family: var(--font-sans);
background-color: var(--color-root);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Subtle noise texture overlay */
.noise-overlay::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
z-index: 9999;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* Glass utility */
.glass {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.glass-heavy {
background: var(--glass-bg-heavy);
border: 1px solid var(--glass-border-heavy);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Keyboard shortcut key styling */
.kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
height: 1.75rem;
padding: 0 0.5rem;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-accent);
background: var(--kbd-bg);
border: 1px solid var(--color-border-medium);
border-radius: 0.25rem;
box-shadow: 0 1px 0 var(--scrollbar-thumb);
}
/* Animation keyframes */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes accentPulse {
0%, 100% {
box-shadow: 0 0 8px var(--glow-accent-strong);
}
50% {
box-shadow: 0 0 20px var(--glow-accent-pulse);
}
}
/* Animation utilities */
.animate-fade-slide-in {
animation: fadeSlideIn 0.3s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out forwards;
}
.animate-accent-pulse {
animation: accentPulse 2s ease-in-out infinite;
}
/* Node selection glow */
.node-glow {
box-shadow: 0 0 20px var(--glow-accent);
}
/* Diff overlay glow effects */
.diff-changed-glow {
box-shadow: 0 0 16px rgba(224, 82, 82, 0.25);
}
.diff-affected-glow {
box-shadow: 0 0 12px rgba(212, 160, 48, 0.2);
}
/* Diff fade for unrelated nodes */
.diff-faded {
opacity: 0.25;
filter: saturate(0.3);
transition: opacity 0.3s ease, filter 0.3s ease;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Hide scrollbar but keep scroll functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom scrollbar for dark luxury theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Override React Flow dark theme */
.react-flow__background {
background-color: var(--color-root) !important;
}
/* Light theme overrides */
[data-theme="light"] {
color-scheme: light;
}
[data-theme="light"] .diff-faded {
opacity: 0.35;
}
[data-theme="light"] ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
}
[data-theme="light"] .warning-banner {
background: rgba(180, 130, 30, 0.1);
border-color: rgba(180, 130, 30, 0.3);
color: #92600a;
}
[data-theme="dark"] {
color-scheme: dark;
}

View File

@@ -0,0 +1,306 @@
export const en = {
common: {
loading: "Loading project...",
noGraphLoaded: "No graph loaded",
selectNode: "Select a node to see details",
back: "Back",
focus: "Focus",
unfocus: "Unfocus",
openCode: "Open code",
file: "File",
tags: "Tags",
connections: "Connections",
filter: "Filter",
resetAll: "Reset All",
analyzed: "Analyzed",
startGuidedTour: "Start Guided Tour",
truncated: "(truncated)",
preview: "Preview",
doubleClickToOpen: "double-click to open",
appName: "Understand Anything",
pressKeyboard: "Press ? for keyboard shortcuts",
path: "Path",
theme: "Theme",
},
projectOverview: {
nodes: "Nodes",
edges: "Edges",
layers: "Layers",
types: "Types",
fileTypes: "File Types",
code: "Code",
config: "Config",
docs: "Docs",
infra: "Infra",
data: "Data",
domain: "Domain",
knowledge: "Knowledge",
languages: "Languages",
frameworks: "Frameworks",
nodeTypeDistribution: "Node Type Distribution",
complexityDistribution: "Complexity Distribution",
simple: "Simple",
moderate: "Moderate",
complex: "Complex",
mostConnectedNodes: "Most Connected Nodes",
avgConnectionsPerNode: "Avg Connections per Node",
},
nodeInfo: {
definedInThisFile: "Defined in this file",
languageConcepts: "Language Concepts",
category: "Category",
wikilinks: "Wikilinks",
backlinks: "Backlinks",
entities: "Entities",
businessRules: "Business Rules",
crossDomain: "Cross-Domain",
flows: "Flows",
entryPoint: "Entry Point",
steps: "Steps",
implementation: "Implementation",
},
fileExplorer: {
analyzedFiles: "Analyzed Files",
filesFromGraph: "files from the current knowledge graph",
noFilePathsFound: "No file paths found.",
},
filterPanel: {
nodeTypes: "Node Types",
complexity: "Complexity",
layers: "Layers",
edgeCategories: "Edge Categories",
},
personaSelector: {
overview: "Overview",
overviewDesc: "High-level architecture view",
learn: "Learn",
learnDesc: "Full dashboard with guided learning",
deepDive: "Deep Dive",
deepDiveDesc: "Code-focused with chat",
},
sidebar: {
info: "Info",
files: "Files",
},
mobile: {
graph: "Graph",
info: "Info",
files: "Files",
},
drawer: {
controls: "Controls",
dashboard: "Dashboard",
role: "Role",
view: "View",
diffOverlay: "Diff overlay",
nodeTypes: "Node types",
layers: "Layers",
tools: "Tools",
path: "Path",
help: "Help",
structural: "Structural",
domain: "Domain",
},
domainView: {
backToDomains: "Back to domains",
},
detailLevel: {
filesTitle: "Files only — architecture-level dependencies (fast)",
classesTitle: "Files + Classes — code structure with inheritance",
files: "Files",
classes: "+Classes",
fnTitle: "Toggle function nodes (may slow down rendering)",
fn: "fn",
},
nodeTypeLabels: {
all: "All",
code: "Code",
config: "Config",
docs: "Docs",
infra: "Infra",
data: "Data",
domain: "Domain",
knowledge: "Knowledge",
},
tokenGate: {
validating: "Validating...",
continue: "Continue",
},
diffToggle: {
hideOverlay: "Hide diff overlay",
showOverlay: "Show diff overlay",
noData: "No diff data loaded",
changed: "Changed",
affected: "Affected",
},
learnPanel: {
finish: "Finish",
next: "Next",
prev: "Prev",
noTour: "No tour available",
noTourHint: "Generate a tour from your knowledge graph to get a guided walkthrough",
projectTour: "Project Tour",
steps: "steps",
stepsTitle: "Steps",
guidedWalkthrough: "Guided walkthrough of the codebase",
startTour: "Start Tour",
tour: "Tour",
exitTour: "Exit Tour",
},
layer: {
defaultName: "Layer",
label: "layers",
},
breadcrumb: {
projectOverview: "Project Overview",
project: "Project",
escBack: "Esc to go back",
},
warningBanner: {
dropped: "Dropped",
fatal: "Fatal",
},
themePicker: {
changeTheme: "Change theme",
theme: "Theme",
accentColor: "Accent Color",
headingFont: "Heading Font",
serif: "Serif",
sans: "Sans",
mono: "Mono",
},
codeViewer: {
fullFile: "Full file",
lines: "Lines",
linesLabel: "lines",
noFile: "No file selected",
loading: "Loading source...",
openLarger: "Open larger code viewer",
closeExpanded: "Close expanded code viewer",
closeViewer: "Close code viewer",
sourceUnavailable: "Source unavailable",
},
customNode: {
tested: "Tested",
hasTests: "Has tests",
},
ariaLabels: {
openMenu: "Open menu",
closeMenu: "Close menu",
settings: "Settings",
hideSearch: "Hide search",
showSearch: "Show search",
},
nodeTypeFilter: {
hide: "Hide",
show: "Show",
nodesLabel: "nodes",
},
keyboardShortcuts: {
showHelp: "Show keyboard shortcuts",
general: "General",
navigation: "Navigation",
tour: "Tour",
view: "View",
focusSearch: "Focus search bar",
nextStep: "Next tour step",
prevStep: "Previous tour step",
toggleDiff: "Toggle diff mode",
toggleFilter: "Toggle filter panel",
toggleExport: "Toggle export menu",
openPathFinder: "Open path finder",
title: "Keyboard Shortcuts",
toggleHint: "Press ? anytime to toggle this help",
closeHint: "Press ESC to close",
escapeDesc: "Close panels and modals / go back to overview",
},
search: {
placeholder: "Search nodes by name, summary, or tags...",
fuzzy: "Fuzzy",
semantic: "Semantic",
result: "result",
},
export: {
label: "Export",
title: "Export graph (E)",
asPNG: "Export as PNG",
asSVG: "Export as SVG",
asJSON: "Export as JSON",
},
edgeLabels: {
imports: { forward: "imports", backward: "imported by" },
exports: { forward: "exports to", backward: "exported by" },
contains: { forward: "contains", backward: "contained in" },
inherits: { forward: "inherits from", backward: "inherited by" },
implements: { forward: "implements", backward: "implemented by" },
calls: { forward: "calls", backward: "called by" },
subscribes: { forward: "subscribes to", backward: "subscribed by" },
publishes: { forward: "publishes to", backward: "consumed by" },
middleware: { forward: "middleware for", backward: "uses middleware" },
reads_from: { forward: "reads from", backward: "read by" },
writes_to: { forward: "writes to", backward: "written by" },
transforms: { forward: "transforms", backward: "transformed by" },
validates: { forward: "validates", backward: "validated by" },
depends_on: { forward: "depends on", backward: "depended on by" },
tested_by: { forward: "tested by", backward: "tests" },
configures: { forward: "configures", backward: "configured by" },
related: { forward: "related to", backward: "related to" },
similar_to: { forward: "similar to", backward: "similar to" },
deploys: { forward: "deploys", backward: "deployed by" },
serves: { forward: "serves", backward: "served by" },
migrates: { forward: "migrates", backward: "migrated by" },
documents: { forward: "documents", backward: "documented by" },
provisions: { forward: "provisions", backward: "provisioned by" },
routes: { forward: "routes to", backward: "routed from" },
defines_schema: { forward: "defines schema for", backward: "schema defined by" },
triggers: { forward: "triggers", backward: "triggered by" },
contains_flow: { forward: "contains flow", backward: "flow in" },
flow_step: { forward: "flow step", backward: "step of" },
cross_domain: { forward: "cross-domain to", backward: "cross-domain from" },
cites: { forward: "cites", backward: "cited by" },
contradicts: { forward: "contradicts", backward: "contradicted by" },
builds_on: { forward: "builds on", backward: "built upon by" },
exemplifies: { forward: "exemplifies", backward: "exemplified by" },
categorized_under: { forward: "categorized under", backward: "categorizes" },
authored_by: { forward: "authored by", backward: "authored" },
},
pathFinder: {
title: "Find path between nodes (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · GET STARTED",
skipForever: "Don't show again",
prev: "Previous",
next: "Next",
finish: "Start exploring",
steps: [
{
title: "Welcome to the knowledge graph",
body: "The dots and lines you see are entities and relations Understand-Anything extracted from this project. A node can be a file, class, or function from the code — or a concept, entity, or claim from a knowledge wiki.",
hint: "Five steps to cover the core operations",
},
{
title: "Three views at the top",
body: "Overview shows the big picture (force-directed). Learn follows a preset learning path. Deep Dive shows type and complexity stats. Each view answers a different question.",
hint: "Decide what you're asking before you switch",
},
{
title: "Search + click a node",
body: "The top search box fuzzy-matches node name / summary / tags. Click any node and the right panel opens with summary, neighbors, and Open Article.",
hint: "Search centers and highlights; clicking a node highlights its edges",
},
{
title: "Layer switch + Project Tour",
body: "The layer tabs next to All filter the graph to one category, sourced from index.md. Project Tour on the right walks you through the editor's preset sequence.",
hint: "Use Layer when nodes are too dense; start Tour when you have no entry point",
},
{
title: "More hidden features",
body: "The top bar also has Filter (by type / complexity), Export (export the graph), Path (find a path between two nodes), and Theme. Press Shift + ? for the full keyboard shortcuts.",
hint: "Expand them when you need them — no need to memorize all at once",
},
],
},
};
export default en;

View File

@@ -0,0 +1,35 @@
import en from "./en";
import zh from "./zh";
import zhTW from "./zh-TW";
import ja from "./ja";
import ko from "./ko";
import ru from "./ru";
export type LocaleKey = "en" | "zh" | "zh-TW" | "ja" | "ko" | "ru";
export type Locale = typeof en;
export const locales: Record<LocaleKey, Locale> = {
en,
zh,
"zh-TW": zhTW,
ja,
ko,
ru,
};
export function getLocale(key: LocaleKey): Locale {
return locales[key] ?? locales.en;
}
export function resolveLocaleKey(lang: string | undefined): LocaleKey {
if (!lang) return "en";
const normalized = lang.toLowerCase().replace(/[_\s]/g, "-");
if (normalized === "zh" || normalized === "chinese" || normalized === "zh-cn") return "zh";
if (normalized === "zh-tw" || normalized === "traditional-chinese") return "zh-TW";
if (normalized === "ja" || normalized === "japanese") return "ja";
if (normalized === "ko" || normalized === "korean") return "ko";
if (normalized === "ru" || normalized === "russian" || normalized === "ru-ru") return "ru";
return "en";
}
export { en, zh, zhTW as "zh-TW", ja, ko, ru };

View File

@@ -0,0 +1,306 @@
export const ja = {
common: {
loading: "プロジェクトを読み込み中...",
noGraphLoaded: "知識グラフが読み込まれていません",
selectNode: "ノードを選択して詳細を表示",
back: "戻る",
focus: "フォーカス",
unfocus: "フォーカス解除",
openCode: "コードを開く",
file: "ファイル",
tags: "タグ",
connections: "接続",
filter: "フィルター",
resetAll: "すべてリセット",
analyzed: "分析日時",
startGuidedTour: "ガイド付きツアーを開始",
truncated: "(省略)",
preview: "プレビュー",
doubleClickToOpen: "ダブルクリックで開く",
appName: "Understand Anything",
pressKeyboard: "? を押してキーボードショートカットを表示",
path: "パス",
theme: "テーマ",
},
projectOverview: {
nodes: "ノード",
edges: "エッジ",
layers: "レイヤー",
types: "タイプ",
fileTypes: "ファイルタイプ",
code: "コード",
config: "設定",
docs: "ドキュメント",
infra: "インフラ",
data: "データ",
domain: "ドメイン",
knowledge: "ナレッジ",
languages: "プログラミング言語",
frameworks: "フレームワーク",
nodeTypeDistribution: "ノードタイプ分布",
complexityDistribution: "複雑度分布",
simple: "単純",
moderate: "中程度",
complex: "複雑",
mostConnectedNodes: "最も接続されているノード",
avgConnectionsPerNode: "ノード平均接続数",
},
nodeInfo: {
definedInThisFile: "このファイルで定義",
languageConcepts: "言語概念",
category: "カテゴリ",
wikilinks: "Wikilinks",
backlinks: "Backlinks",
entities: "エンティティ",
businessRules: "ビジネスルール",
crossDomain: "クロスドメイン",
flows: "フロー",
entryPoint: "エントリポイント",
steps: "ステップ",
implementation: "実装",
},
fileExplorer: {
analyzedFiles: "分析済みファイル",
filesFromGraph: "現在の知識グラフからのファイル",
noFilePathsFound: "ファイルパスが見つかりません。",
},
filterPanel: {
nodeTypes: "ノードタイプ",
complexity: "複雑度",
layers: "レイヤー",
edgeCategories: "エッジカテゴリ",
},
personaSelector: {
overview: "概要",
overviewDesc: "高レベルアーキテクチャビュー",
learn: "学習",
learnDesc: "ガイド付き学習付き完全ダッシュボード",
deepDive: "詳細",
deepDiveDesc: "コード中心のチャット",
},
sidebar: {
info: "情報",
files: "ファイル",
},
mobile: {
graph: "グラフ",
info: "情報",
files: "ファイル",
},
drawer: {
controls: "コントロール",
dashboard: "ダッシュボード",
role: "ロール",
view: "ビュー",
diffOverlay: "差分オーバーレイ",
nodeTypes: "ノードタイプ",
layers: "レイヤー",
tools: "ツール",
path: "パス",
help: "ヘルプ",
structural: "構造",
domain: "ドメイン",
},
domainView: {
backToDomains: "ドメインに戻る",
},
detailLevel: {
filesTitle: "ファイルのみ — アーキテクチャレベルの依存関係(高速)",
classesTitle: "ファイル + クラス — 継承を含むコード構造",
files: "ファイル",
classes: "+クラス",
fnTitle: "関数ノードを切り替え(レンダリングが遅くなる可能性)",
fn: "fn",
},
nodeTypeLabels: {
all: "すべて",
code: "コード",
config: "設定",
docs: "ドキュメント",
infra: "インフラ",
data: "データ",
domain: "ドメイン",
knowledge: "ナレッジ",
},
tokenGate: {
validating: "検証中...",
continue: "続行",
},
diffToggle: {
hideOverlay: "差分オーバーレイを非表示",
showOverlay: "差分オーバーレイを表示",
noData: "差分データが読み込まれていません",
changed: "変更済み",
affected: "影響あり",
},
learnPanel: {
finish: "完了",
next: "次へ",
prev: "前へ",
noTour: "ツアーがありません",
noTourHint: "知識グラフからツアーを生成してコードベースのガイド付きウォークスルーを取得",
projectTour: "プロジェクトツアー",
steps: "ステップ",
stepsTitle: "ステップ",
guidedWalkthrough: "コードベースのガイド付きウォークスルー",
startTour: "ツアー開始",
tour: "ツアー",
exitTour: "ツアー終了",
},
layer: {
defaultName: "レイヤー",
label: "レイヤー",
},
breadcrumb: {
projectOverview: "プロジェクト概要",
project: "プロジェクト",
escBack: "Escで戻る",
},
warningBanner: {
dropped: "削除済み",
fatal: "致命的",
},
themePicker: {
changeTheme: "テーマ変更",
theme: "テーマ",
accentColor: "アクセント色",
headingFont: "見出しフォント",
serif: "セリフ",
sans: "サン",
mono: "モノ",
},
codeViewer: {
fullFile: "ファイル全体",
lines: "行",
linesLabel: "行",
noFile: "ファイル未選択",
loading: "ソース読み込み中...",
openLarger: "大きなコードビューアを開く",
closeExpanded: "展開したコードビューアを閉じる",
closeViewer: "コードビューアを閉じる",
sourceUnavailable: "ソースが利用できません",
},
customNode: {
tested: "テスト済み",
hasTests: "テストあり",
},
ariaLabels: {
openMenu: "メニューを開く",
closeMenu: "メニューを閉じる",
settings: "設定",
hideSearch: "検索を非表示",
showSearch: "検索を表示",
},
nodeTypeFilter: {
hide: "非表示",
show: "表示",
nodesLabel: "ノード",
},
keyboardShortcuts: {
showHelp: "キーボードショートカットを表示",
general: "一般",
navigation: "ナビゲーション",
tour: "ツアー",
view: "ビュー",
focusSearch: "検索バーにフォーカス",
nextStep: "次のツアーステップ",
prevStep: "前のツアーステップ",
toggleDiff: "差分モード切り替え",
toggleFilter: "フィルターパネル切り替え",
toggleExport: "エクスポートメニュー切り替え",
openPathFinder: "パスファインダーを開く",
title: "キーボードショートカット",
toggleHint: "いつでも ? を押してこのヘルプを切り替え",
closeHint: "ESC を押して閉じる",
escapeDesc: "パネルとモーダルを閉じる / 概要に戻る",
},
search: {
placeholder: "ノード名、概要、タグで検索...",
fuzzy: "ファジー",
semantic: "セマンティック",
result: "結果",
},
export: {
label: "エクスポート",
title: "グラフをエクスポート (E)",
asPNG: "PNGでエクスポート",
asSVG: "SVGでエクスポート",
asJSON: "JSONでエクスポート",
},
edgeLabels: {
imports: { forward: "インポート", backward: "インポートされる" },
exports: { forward: "エクスポート", backward: "エクスポートされる" },
contains: { forward: "含む", backward: "含まれる" },
inherits: { forward: "継承", backward: "継承される" },
implements: { forward: "実装", backward: "実装される" },
calls: { forward: "呼び出す", backward: "呼び出される" },
subscribes: { forward: "購読", backward: "購読される" },
publishes: { forward: "公開", backward: "消費される" },
middleware: { forward: "ミドルウェア", backward: "ミドルウェアを使用" },
reads_from: { forward: "読み取り", backward: "読み取られる" },
writes_to: { forward: "書き込み", backward: "書き込まれる" },
transforms: { forward: "変換", backward: "変換される" },
validates: { forward: "検証", backward: "検証される" },
depends_on: { forward: "依存", backward: "依存される" },
tested_by: { forward: "テストされる", backward: "テスト" },
configures: { forward: "設定", backward: "設定される" },
related: { forward: "関連", backward: "関連" },
similar_to: { forward: "類似", backward: "類似" },
deploys: { forward: "デプロイ", backward: "デプロイされる" },
serves: { forward: "提供", backward: "提供される" },
migrates: { forward: "移行", backward: "移行される" },
documents: { forward: "ドキュメント化", backward: "ドキュメント化される" },
provisions: { forward: "提供", backward: "提供される" },
routes: { forward: "ルーティング", backward: "ルーティングされる" },
defines_schema: { forward: "スキーマ定義", backward: "スキーマ定義される" },
triggers: { forward: "トリガー", backward: "トリガーされる" },
contains_flow: { forward: "フローを含む", backward: "フロー内" },
flow_step: { forward: "フローステップ", backward: "ステップの" },
cross_domain: { forward: "クロスドメイン", backward: "クロスドメインから" },
cites: { forward: "引用", backward: "引用される" },
contradicts: { forward: "矛盾", backward: "矛盾される" },
builds_on: { forward: "基礎", backward: "基礎となる" },
exemplifies: { forward: "例示", backward: "例示される" },
categorized_under: { forward: "カテゴリ化", backward: "カテゴリ化する" },
authored_by: { forward: "作成者", backward: "作成" },
},
pathFinder: {
title: "ノード間のパスを検索 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · はじめに",
skipForever: "次回から表示しない",
prev: "前へ",
next: "次へ",
finish: "探索を始める",
steps: [
{
title: "知識グラフへようこそ",
body: "表示されているードとエッジは、Understand-Anything がこのプロジェクトから抽出したエンティティと関係です。ノードはコード側のファイル・クラス・関数のこともあれば、知識 wiki 側の概念・エンティティ・記述のこともあります。",
hint: "5 ステップで主要な操作を確認します",
},
{
title: "上部の 3 つのビュー",
body: "Overview は全体像力学的レイアウト、Learn はあらかじめ用意された学習パス、Deep Dive はタイプ / 複雑度の統計を表示します。それぞれ異なる問いに答えるためのビューです。",
hint: "切り替える前に、何を知りたいかを明確に",
},
{
title: "検索 + ノードクリック",
body: "上部の検索ボックスはノード名 / summary / タグをあいまい検索します。任意のノードをクリックすると、右側のパネルに summary、隣接ード、Open Article ボタンが表示されます。",
hint: "検索はノードを中央寄せ・ハイライト、クリックは隣接エッジをハイライトします",
},
{
title: "Layer 切替 + Project Tour",
body: "上部 All の隣にある layer タブは index.md に基づいて 1 つのカテゴリだけを表示します。右側の Project Tour は編集者が用意した順序でガイドします。",
hint: "ノードが多すぎるときは Layer、入り口がわからないときは Tour",
},
{
title: "その他の隠れた機能",
body: "上部バーには Filterタイプ / 複雑度で絞り込み、Exportグラフを書き出す、Path2 つのード間のパスを検索、Themeテーマ切替もあります。Shift + ? で全キーボードショートカットを確認できます。",
hint: "必要になったときに開けば十分。一度に覚える必要はありません",
},
],
},
};
export default ja;

View File

@@ -0,0 +1,306 @@
export const ko = {
common: {
loading: "프로젝트 로딩 중...",
noGraphLoaded: "지식 그래프가 로드되지 않음",
selectNode: "노드를 선택하여 상세 정보 확인",
back: "뒤로",
focus: "포커스",
unfocus: "포커스 해제",
openCode: "코드 열기",
file: "파일",
tags: "태그",
connections: "연결",
filter: "필터",
resetAll: "모두 재설정",
analyzed: "분석 시간",
startGuidedTour: "가이드 투어 시작",
truncated: "(생략)",
preview: "미리보기",
doubleClickToOpen: "두 번 클릭하여 열기",
appName: "Understand Anything",
pressKeyboard: "? 키를 눌러 키보드 단축키 보기",
path: "경로",
theme: "테마",
},
projectOverview: {
nodes: "노드",
edges: "엣지",
layers: "레이어",
types: "타입",
fileTypes: "파일 타입",
code: "코드",
config: "설정",
docs: "문서",
infra: "인프라",
data: "데이터",
domain: "도메인",
knowledge: "지식",
languages: "프로그래밍 언어",
frameworks: "프레임워크",
nodeTypeDistribution: "노드 타입 분포",
complexityDistribution: "복잡도 분포",
simple: "단순",
moderate: "중간",
complex: "복잡",
mostConnectedNodes: "가장 많이 연결된 노드",
avgConnectionsPerNode: "노드 평균 연결 수",
},
nodeInfo: {
definedInThisFile: "이 파일에 정義",
languageConcepts: "언어 개념",
category: "카테고리",
wikilinks: "Wikilinks",
backlinks: "Backlinks",
entities: "엔티티",
businessRules: "비즈니스 규칙",
crossDomain: "크로스 도메인",
flows: "플로우",
entryPoint: "진입점",
steps: "단계",
implementation: "구현",
},
fileExplorer: {
analyzedFiles: "분석된 파일",
filesFromGraph: "현재 지식 그래프의 파일",
noFilePathsFound: "파일 경로를 찾을 수 없습니다.",
},
filterPanel: {
nodeTypes: "노드 타입",
complexity: "복잡도",
layers: "레이어",
edgeCategories: "엣지 카테고리",
},
personaSelector: {
overview: "개요",
overviewDesc: "고수준 아키텍처 뷰",
learn: "학습",
learnDesc: "가이드 학습 포함 완전 대시보드",
deepDive: "심층",
deepDiveDesc: "코드 중심 채팅",
},
sidebar: {
info: "정보",
files: "파일",
},
mobile: {
graph: "그래프",
info: "정보",
files: "파일",
},
drawer: {
controls: "컨트롤",
dashboard: "대시보드",
role: "역할",
view: "보기",
diffOverlay: "차분 오버레이",
nodeTypes: "노드 타입",
layers: "레이어",
tools: "도구",
path: "경로",
help: "도움말",
structural: "구조",
domain: "도메인",
},
domainView: {
backToDomains: "도메인으로 돌아가기",
},
detailLevel: {
filesTitle: "파일만 — 아키텍처 레벨 의존성 (빠름)",
classesTitle: "파일 + 클래스 — 상속 포함 코드 구조",
files: "파일",
classes: "+클래스",
fnTitle: "함수 노드 토글 (렌더링 속도 저하 가능)",
fn: "fn",
},
nodeTypeLabels: {
all: "모두",
code: "코드",
config: "설정",
docs: "문서",
infra: "인프라",
data: "데이터",
domain: "도메인",
knowledge: "지식",
},
tokenGate: {
validating: "검증 중...",
continue: "계속",
},
diffToggle: {
hideOverlay: "차분 오버레이 숨기기",
showOverlay: "차분 오버레이 표시",
noData: "차분 데이터가 로드되지 않음",
changed: "변경됨",
affected: "영향받음",
},
learnPanel: {
finish: "완료",
next: "다음",
prev: "이전",
noTour: "투어 없음",
noTourHint: "지식 그래프에서 투어를 생성하여 코드베이스의 가이드 워크스루를 얻으세요",
projectTour: "프로젝트 투어",
steps: "단계",
stepsTitle: "단계",
guidedWalkthrough: "코드베이스 가이드 워크스루",
startTour: "투어 시작",
tour: "투어",
exitTour: "투어 종료",
},
layer: {
defaultName: "레이어",
label: "레이어",
},
breadcrumb: {
projectOverview: "프로젝트 개요",
project: "프로젝트",
escBack: "Esc로 돌아가기",
},
warningBanner: {
dropped: "삭제됨",
fatal: "치명적",
},
themePicker: {
changeTheme: "테마 변경",
theme: "테마",
accentColor: "강조색",
headingFont: "제목 폰트",
serif: "세리프",
sans: "산스",
mono: "모노",
},
codeViewer: {
fullFile: "전체 파일",
lines: "행",
linesLabel: "행",
noFile: "파일 선택 안 됨",
loading: "소스 로딩 중...",
openLarger: "더 큰 코드 뷰어 열기",
closeExpanded: "확장된 코드 뷰어 닫기",
closeViewer: "코드 뷰어 닫기",
sourceUnavailable: "소스 사용 불가",
},
customNode: {
tested: "테스트됨",
hasTests: "테스트 있음",
},
ariaLabels: {
openMenu: "메뉴 열기",
closeMenu: "메뉴 닫기",
settings: "설정",
hideSearch: "검색 숨기기",
showSearch: "검색 표시",
},
nodeTypeFilter: {
hide: "숨기기",
show: "표시",
nodesLabel: "노드",
},
keyboardShortcuts: {
showHelp: "키보드 단축키 표시",
general: "일반",
navigation: "탐색",
tour: "투어",
view: "보기",
focusSearch: "검색창 포커스",
nextStep: "다음 투어 단계",
prevStep: "이전 투어 단계",
toggleDiff: "차분 모드 전환",
toggleFilter: "필터 패널 전환",
toggleExport: "내보내기 메뉴 전환",
openPathFinder: "경로 찾기 열기",
title: "키보드 단축키",
toggleHint: "언제든 ?를 눌러 이 도움말을 토글",
closeHint: "ESC를 눌러 닫기",
escapeDesc: "패널 및 모달 닫기 / 개요로 돌아가기",
},
search: {
placeholder: "노드 이름, 요약, 태그로 검색...",
fuzzy: "퍼지",
semantic: "시맨틱",
result: "결과",
},
export: {
label: "내보내기",
title: "그래프 내보내기 (E)",
asPNG: "PNG로 내보내기",
asSVG: "SVG로 내보내기",
asJSON: "JSON으로 내보내기",
},
edgeLabels: {
imports: { forward: "임포트", backward: "임포트됨" },
exports: { forward: "내보내기", backward: "내보내기됨" },
contains: { forward: "포함", backward: "포함됨" },
inherits: { forward: "상속", backward: "상속됨" },
implements: { forward: "구현", backward: "구현됨" },
calls: { forward: "호출", backward: "호출됨" },
subscribes: { forward: "구독", backward: "구독됨" },
publishes: { forward: "게시", backward: "소비됨" },
middleware: { forward: "미들웨어", backward: "미들웨어 사용" },
reads_from: { forward: "읽기", backward: "읽기됨" },
writes_to: { forward: "쓰기", backward: "쓰기됨" },
transforms: { forward: "변환", backward: "변환됨" },
validates: { forward: "검증", backward: "검증됨" },
depends_on: { forward: "종속", backward: "종속됨" },
tested_by: { forward: "테스트됨", backward: "테스트" },
configures: { forward: "설정", backward: "설정됨" },
related: { forward: "관련", backward: "관련" },
similar_to: { forward: "유사", backward: "유사" },
deploys: { forward: "배포", backward: "배포됨" },
serves: { forward: "서비스", backward: "서비스됨" },
migrates: { forward: "마이그레이션", backward: "마이그레이션됨" },
documents: { forward: "문서화", backward: "문서화됨" },
provisions: { forward: "제공", backward: "제공됨" },
routes: { forward: "라우팅", backward: "라우팅됨" },
defines_schema: { forward: "스키마 정의", backward: "스키마 정의됨" },
triggers: { forward: "트리거", backward: "트리거됨" },
contains_flow: { forward: "플로우 포함", backward: "플로우 내" },
flow_step: { forward: "플로우 단계", backward: "단계의" },
cross_domain: { forward: "크로스 도메인", backward: "크로스 도메인에서" },
cites: { forward: "인용", backward: "인용됨" },
contradicts: { forward: "반박", backward: "반박됨" },
builds_on: { forward: "기반", backward: "기반됨" },
exemplifies: { forward: "예시", backward: "예시됨" },
categorized_under: { forward: "카테고리화", backward: "카테고리화함" },
authored_by: { forward: "작성자", backward: "작성" },
},
pathFinder: {
title: "노드 간 경로 찾기 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · 시작하기",
skipForever: "다시 보지 않기",
prev: "이전",
next: "다음",
finish: "탐색 시작",
steps: [
{
title: "지식 그래프에 오신 것을 환영합니다",
body: "보이는 점과 선은 Understand-Anything이 이 프로젝트에서 추출한 엔티티와 관계입니다. 노드는 코드 쪽의 파일·클래스·함수일 수도 있고, 지식 위키 쪽의 개념·엔티티·진술일 수도 있습니다.",
hint: "5단계로 핵심 조작을 살펴봅니다",
},
{
title: "상단의 세 가지 뷰",
body: "Overview는 전체 모습(포스 디렉티드), Learn은 미리 정의된 학습 경로, Deep Dive는 타입 / 복잡도 통계를 보여줍니다. 각 뷰는 서로 다른 질문에 답합니다.",
hint: "전환하기 전에 무엇을 묻고 싶은지 정하세요",
},
{
title: "검색 + 노드 클릭",
body: "상단 검색창은 노드 이름 / summary / 태그를 퍼지 매칭합니다. 노드를 클릭하면 오른쪽 패널에 summary, 이웃 목록, Open Article 버튼이 나타납니다.",
hint: "검색은 노드를 중앙 정렬·강조하고, 클릭은 인접 엣지를 강조합니다",
},
{
title: "Layer 전환 + Project Tour",
body: "상단 All 옆의 layer 탭은 index.md를 기반으로 한 카테고리만 표시합니다. 오른쪽의 Project Tour는 편집자가 설정한 순서대로 안내합니다.",
hint: "노드가 너무 빽빽하면 Layer, 시작점이 없으면 Tour를 사용하세요",
},
{
title: "숨겨진 추가 기능",
body: "상단 바에는 Filter(타입 / 복잡도로 필터링), Export(그래프 내보내기), Path(두 노드 사이 경로 찾기), Theme(테마 전환)도 있습니다. Shift + ?를 누르면 전체 키보드 단축키를 볼 수 있습니다.",
hint: "필요할 때 펼쳐 보면 됩니다. 한 번에 다 외울 필요는 없습니다",
},
],
},
};
export default ko;

View File

@@ -0,0 +1,306 @@
export const ru = {
common: {
loading: "Загрузка проекта...",
noGraphLoaded: "Граф знаний не загружен",
selectNode: "Выберите узел, чтобы увидеть подробности",
back: "Назад",
focus: "Фокус",
unfocus: "Снять фокус",
openCode: "Открыть код",
file: "Файл",
tags: "Теги",
connections: "Связи",
filter: "Фильтр",
resetAll: "Сбросить всё",
analyzed: "Проанализировано",
startGuidedTour: "Начать обзор",
truncated: "(сокращено)",
preview: "Предпросмотр",
doubleClickToOpen: "двойной клик, чтобы открыть",
appName: "Understand Anything",
pressKeyboard: "Нажмите ? для горячих клавиш",
path: "Путь",
theme: "Тема",
},
projectOverview: {
nodes: "Узлы",
edges: "Рёбра",
layers: "Слои",
types: "Типы",
fileTypes: "Типы файлов",
code: "Код",
config: "Конфиг",
docs: "Документация",
infra: "Инфраструктура",
data: "Данные",
domain: "Домен",
knowledge: "Знания",
languages: "Языки",
frameworks: "Фреймворки",
nodeTypeDistribution: "Распределение типов узлов",
complexityDistribution: "Распределение сложности",
simple: "Простой",
moderate: "Средний",
complex: "Сложный",
mostConnectedNodes: "Самые связанные узлы",
avgConnectionsPerNode: "Среднее число связей на узел",
},
nodeInfo: {
definedInThisFile: "Определено в этом файле",
languageConcepts: "Концепции языка",
category: "Категория",
wikilinks: "Wiki-ссылки",
backlinks: "Обратные ссылки",
entities: "Сущности",
businessRules: "Бизнес-правила",
crossDomain: "Междоменные связи",
flows: "Потоки",
entryPoint: "Точка входа",
steps: "Шаги",
implementation: "Реализация",
},
fileExplorer: {
analyzedFiles: "Проанализированные файлы",
filesFromGraph: "файлы из текущего графа знаний",
noFilePathsFound: "Пути файлов не найдены.",
},
filterPanel: {
nodeTypes: "Типы узлов",
complexity: "Сложность",
layers: "Слои",
edgeCategories: "Категории рёбер",
},
personaSelector: {
overview: "Обзор",
overviewDesc: "Высокоуровневый архитектурный вид",
learn: "Обучение",
learnDesc: "Полная панель с пошаговым обучением",
deepDive: "Погружение",
deepDiveDesc: "Фокус на коде с чатом",
},
sidebar: {
info: "Информация",
files: "Файлы",
},
mobile: {
graph: "Граф",
info: "Информация",
files: "Файлы",
},
drawer: {
controls: "Управление",
dashboard: "Панель",
role: "Роль",
view: "Вид",
diffOverlay: "Наложение изменений",
nodeTypes: "Типы узлов",
layers: "Слои",
tools: "Инструменты",
path: "Путь",
help: "Помощь",
structural: "Структура",
domain: "Домен",
},
domainView: {
backToDomains: "Назад к доменам",
},
detailLevel: {
filesTitle: "Только файлы — зависимости архитектурного уровня (быстро)",
classesTitle: "Файлы + классы — структура кода с наследованием",
files: "Файлы",
classes: "+Классы",
fnTitle: "Переключить узлы функций (может замедлить отрисовку)",
fn: "fn",
},
nodeTypeLabels: {
all: "Все",
code: "Код",
config: "Конфиг",
docs: "Документация",
infra: "Инфраструктура",
data: "Данные",
domain: "Домен",
knowledge: "Знания",
},
tokenGate: {
validating: "Проверка...",
continue: "Продолжить",
},
diffToggle: {
hideOverlay: "Скрыть наложение изменений",
showOverlay: "Показать наложение изменений",
noData: "Данные об изменениях не загружены",
changed: "Изменено",
affected: "Затронуто",
},
learnPanel: {
finish: "Завершить",
next: "Далее",
prev: "Назад",
noTour: "Обзор недоступен",
noTourHint: "Сгенерируйте обзор из графа знаний, чтобы получить пошаговое руководство по кодовой базе",
projectTour: "Обзор проекта",
steps: "шагов",
stepsTitle: "Шаги",
guidedWalkthrough: "Пошаговое знакомство с кодовой базой",
startTour: "Начать обзор",
tour: "Обзор",
exitTour: "Завершить обзор",
},
layer: {
defaultName: "Слой",
label: "слои",
},
breadcrumb: {
projectOverview: "Обзор проекта",
project: "Проект",
escBack: "Esc — назад",
},
warningBanner: {
dropped: "Отброшено",
fatal: "Критично",
},
themePicker: {
changeTheme: "Сменить тему",
theme: "Тема",
accentColor: "Акцентный цвет",
headingFont: "Шрифт заголовков",
serif: "Серифный",
sans: "Без засечек",
mono: "Моноширинный",
},
codeViewer: {
fullFile: "Весь файл",
lines: "Строки",
linesLabel: "строк",
noFile: "Файл не выбран",
loading: "Загрузка исходного кода...",
openLarger: "Открыть увеличенный просмотрщик кода",
closeExpanded: "Закрыть расширенный просмотрщик кода",
closeViewer: "Закрыть просмотрщик кода",
sourceUnavailable: "Исходный код недоступен",
},
customNode: {
tested: "Покрыт тестами",
hasTests: "Есть тесты",
},
ariaLabels: {
openMenu: "Открыть меню",
closeMenu: "Закрыть меню",
settings: "Настройки",
hideSearch: "Скрыть поиск",
showSearch: "Показать поиск",
},
nodeTypeFilter: {
hide: "Скрыть",
show: "Показать",
nodesLabel: "узлов",
},
keyboardShortcuts: {
showHelp: "Показать горячие клавиши",
general: "Общие",
navigation: "Навигация",
tour: "Обзор",
view: "Вид",
focusSearch: "Перейти к строке поиска",
nextStep: "Следующий шаг обзора",
prevStep: "Предыдущий шаг обзора",
toggleDiff: "Переключить режим изменений",
toggleFilter: "Переключить панель фильтров",
toggleExport: "Переключить меню экспорта",
openPathFinder: "Открыть поиск пути",
title: "Горячие клавиши",
toggleHint: "Нажмите ?, чтобы открыть или закрыть эту справку",
closeHint: "Нажмите ESC, чтобы закрыть",
escapeDesc: "Закрыть панели и модальные окна / вернуться к обзору",
},
search: {
placeholder: "Поиск узлов по имени, описанию или тегам...",
fuzzy: "Нечёткий",
semantic: "Семантический",
result: "результат",
},
export: {
label: "Экспорт",
title: "Экспортировать граф (E)",
asPNG: "Экспортировать как PNG",
asSVG: "Экспортировать как SVG",
asJSON: "Экспортировать как JSON",
},
edgeLabels: {
imports: { forward: "импортирует", backward: "импортируется" },
exports: { forward: "экспортирует в", backward: "экспортируется" },
contains: { forward: "содержит", backward: "содержится в" },
inherits: { forward: "наследует от", backward: "наследуется" },
implements: { forward: "реализует", backward: "реализуется" },
calls: { forward: "вызывает", backward: "вызывается" },
subscribes: { forward: "подписывается на", backward: "подписан" },
publishes: { forward: "публикует в", backward: "получает события" },
middleware: { forward: "middleware для", backward: "использует middleware" },
reads_from: { forward: "читает из", backward: "читается" },
writes_to: { forward: "пишет в", backward: "записывается" },
transforms: { forward: "преобразует", backward: "преобразуется" },
validates: { forward: "валидирует", backward: "валидируется" },
depends_on: { forward: "зависит от", backward: "является зависимостью" },
tested_by: { forward: "тестируется", backward: "тестирует" },
configures: { forward: "конфигурирует", backward: "конфигурируется" },
related: { forward: "связан с", backward: "связан с" },
similar_to: { forward: "похож на", backward: "похож на" },
deploys: { forward: "разворачивает", backward: "разворачивается" },
serves: { forward: "обслуживает", backward: "обслуживается" },
migrates: { forward: "мигрирует", backward: "мигрируется" },
documents: { forward: "документирует", backward: "документируется" },
provisions: { forward: "обеспечивает", backward: "обеспечивается" },
routes: { forward: "маршрутизирует в", backward: "маршрутизируется из" },
defines_schema: { forward: "определяет схему для", backward: "схема определена" },
triggers: { forward: "запускает", backward: "запускается" },
contains_flow: { forward: "содержит поток", backward: "поток в" },
flow_step: { forward: "шаг потока", backward: "шаг" },
cross_domain: { forward: "междоменно к", backward: "междоменно из" },
cites: { forward: "цитирует", backward: "цитируется" },
contradicts: { forward: "противоречит", backward: "опровергается" },
builds_on: { forward: "основан на", backward: "основа для" },
exemplifies: { forward: "иллюстрирует", backward: "иллюстрируется" },
categorized_under: { forward: "относится к", backward: "категоризирует" },
authored_by: { forward: "автор", backward: "автор" },
},
pathFinder: {
title: "Найти путь между узлами (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · НАЧАЛО РАБОТЫ",
skipForever: "Больше не показывать",
prev: "Назад",
next: "Далее",
finish: "Начать исследование",
steps: [
{
title: "Добро пожаловать в граф знаний",
body: "Точки и линии — это сущности и связи, извлечённые Understand-Anything из этого проекта. Узлом может быть файл, класс или функция из кода — либо концепция, сущность или утверждение из вики знаний.",
hint: "Пять шагов охватят основные операции",
},
{
title: "Три вида сверху",
body: "Overview показывает общую картину (force-directed). Learn ведёт по заранее заданному учебному пути. Deep Dive показывает статистику по типам и сложности. Каждый вид отвечает на свой вопрос.",
hint: "Перед переключением определитесь, о чём вы спрашиваете",
},
{
title: "Поиск + клик по узлу",
body: "Поисковая строка сверху делает нечёткое совпадение по имени узла, summary и тегам. Кликните по узлу — справа откроется панель с summary, соседями и кнопкой Open Article.",
hint: "Поиск центрирует и подсвечивает; клик подсвечивает соседние рёбра",
},
{
title: "Переключение Layer + Project Tour",
body: "Вкладки layer рядом с All фильтруют граф по одной категории на основе index.md. Project Tour справа проводит вас по заранее заданной последовательности.",
hint: "Используйте Layer, когда узлов слишком много; запустите Tour, если непонятно с чего начать",
},
{
title: "Другие скрытые возможности",
body: "В верхней панели также есть Filter (фильтр по типу / сложности), Export (экспорт графа), Path (поиск пути между двумя узлами) и Theme (смена темы). Нажмите Shift + ?, чтобы увидеть полный список горячих клавиш.",
hint: "Открывайте их по мере необходимости — не нужно запоминать всё сразу",
},
],
},
};
export default ru;

View File

@@ -0,0 +1,306 @@
export const zhTW = {
common: {
loading: "載入專案...",
noGraphLoaded: "未載入知識圖谱",
selectNode: "選擇節點查看詳情",
back: "返回",
focus: "聚焦",
unfocus: "取消聚焦",
openCode: "開啟程式碼",
file: "檔案",
tags: "標籤",
connections: "連結",
filter: "篩選",
resetAll: "重置全部",
analyzed: "分析時間",
startGuidedTour: "開始導覽",
truncated: "(已截斷)",
preview: "預覽",
doubleClickToOpen: "雙擊開啟",
appName: "Understand Anything",
pressKeyboard: "按 ? 查看鍵盤快捷鍵",
path: "路徑",
theme: "主題",
},
projectOverview: {
nodes: "節點",
edges: "邊",
layers: "層級",
types: "類型",
fileTypes: "檔案類型",
code: "程式碼",
config: "配置",
docs: "文件",
infra: "基礎設施",
data: "資料",
domain: "領域",
knowledge: "知識",
languages: "程式語言",
frameworks: "框架",
nodeTypeDistribution: "節點類型分布",
complexityDistribution: "複雜度分布",
simple: "簡單",
moderate: "中等",
complex: "複雜",
mostConnectedNodes: "連結最多的節點",
avgConnectionsPerNode: "節點平均連結數",
},
nodeInfo: {
definedInThisFile: "在此檔案中定義",
languageConcepts: "語言概念",
category: "分類",
wikilinks: "維基連結",
backlinks: "反向連結",
entities: "實體",
businessRules: "業務規則",
crossDomain: "跨領域",
flows: "流程",
entryPoint: "入口點",
steps: "步驟",
implementation: "實作",
},
fileExplorer: {
analyzedFiles: "已分析檔案",
filesFromGraph: "來自目前知識圖谱的檔案",
noFilePathsFound: "未找到檔案路徑。",
},
filterPanel: {
nodeTypes: "節點類型",
complexity: "複雜度",
layers: "層級",
edgeCategories: "邊類別",
},
personaSelector: {
overview: "概覽",
overviewDesc: "高層次架構視圖",
learn: "學習",
learnDesc: "完整儀表板與導覽學習",
deepDive: "深入",
deepDiveDesc: "程式碼聚焦與對話",
},
sidebar: {
info: "資訊",
files: "檔案",
},
mobile: {
graph: "圖谱",
info: "資訊",
files: "檔案",
},
drawer: {
controls: "控制",
dashboard: "儀表板",
role: "角色",
view: "視圖",
diffOverlay: "差異覆蓋",
nodeTypes: "節點類型",
layers: "層級",
tools: "工具",
path: "路徑",
help: "幫助",
structural: "結構",
domain: "領域",
},
domainView: {
backToDomains: "返回領域列表",
},
detailLevel: {
filesTitle: "僅檔案 — 架構級依賴(快速)",
classesTitle: "檔案 + 類別 — 程式碼結構及繼承關係",
files: "檔案",
classes: "+類別",
fnTitle: "切換函數節點(可能降低渲染速度)",
fn: "函數",
},
nodeTypeLabels: {
all: "全部",
code: "程式碼",
config: "配置",
docs: "文件",
infra: "基礎設施",
data: "資料",
domain: "領域",
knowledge: "知識",
},
tokenGate: {
validating: "驗證中...",
continue: "繼續",
},
diffToggle: {
hideOverlay: "隱藏差異覆蓋",
showOverlay: "顯示差異覆蓋",
noData: "未載入差異資料",
changed: "已修改",
affected: "受影響",
},
learnPanel: {
finish: "完成",
next: "下一步",
prev: "上一步",
noTour: "無導覽可用",
noTourHint: "從知識圖谱生成導覽以獲取程式碼庫的引導式講解",
projectTour: "專案導覽",
steps: "步",
stepsTitle: "步驟",
guidedWalkthrough: "程式碼庫引導式講解",
startTour: "開始導覽",
tour: "導覽",
exitTour: "退出導覽",
},
layer: {
defaultName: "層級",
label: "層",
},
breadcrumb: {
projectOverview: "專案概覽",
project: "專案",
escBack: "按 Esc 返回",
},
warningBanner: {
dropped: "已捨棄",
fatal: "致命錯誤",
},
themePicker: {
changeTheme: "變更主題",
theme: "主題",
accentColor: "強調色",
headingFont: "標題字型",
serif: "襯線",
sans: "無襯線",
mono: "等寬",
},
codeViewer: {
fullFile: "完整檔案",
lines: "行",
linesLabel: "行",
noFile: "未選擇檔案",
loading: "載入原始碼中...",
openLarger: "開啟更大的程式碼檢視器",
closeExpanded: "關閉展開的程式碼檢視器",
closeViewer: "關閉程式碼檢視器",
sourceUnavailable: "原始碼不可用",
},
customNode: {
tested: "已測試",
hasTests: "有測試",
},
ariaLabels: {
openMenu: "開啟選單",
closeMenu: "關閉選單",
settings: "設定",
hideSearch: "隱藏搜尋",
showSearch: "顯示搜尋",
},
nodeTypeFilter: {
hide: "隱藏",
show: "顯示",
nodesLabel: "節點",
},
keyboardShortcuts: {
showHelp: "顯示鍵盤快捷鍵",
general: "一般",
navigation: "導航",
tour: "導覽",
view: "檢視",
focusSearch: "聚焦搜尋列",
nextStep: "下一步導覽",
prevStep: "上一步導覽",
toggleDiff: "切換差異模式",
toggleFilter: "切換篩選面板",
toggleExport: "切換匯出選單",
openPathFinder: "開啟路徑尋找器",
title: "鍵盤快捷鍵",
toggleHint: "按 ? 隨時切換此幫助",
closeHint: "按 ESC 關閉",
escapeDesc: "關閉面板和彈窗 / 返回概覽",
},
search: {
placeholder: "搜尋節點名稱、摘要或標籤...",
fuzzy: "模糊",
semantic: "語意",
result: "結果",
},
export: {
label: "匯出",
title: "匯出圖谱 (E)",
asPNG: "匯出為 PNG",
asSVG: "匯出為 SVG",
asJSON: "匯出為 JSON",
},
edgeLabels: {
imports: { forward: "導入", backward: "被導入" },
exports: { forward: "導出到", backward: "被導出" },
contains: { forward: "包含", backward: "被包含" },
inherits: { forward: "繼承自", backward: "被繼承" },
implements: { forward: "實作", backward: "被實作" },
calls: { forward: "呼叫", backward: "被呼叫" },
subscribes: { forward: "訂閱", backward: "被訂閱" },
publishes: { forward: "發布到", backward: "被消費" },
middleware: { forward: "中介軟體", backward: "使用中介軟體" },
reads_from: { forward: "讀取", backward: "被讀取" },
writes_to: { forward: "寫入", backward: "被寫入" },
transforms: { forward: "轉換", backward: "被轉換" },
validates: { forward: "驗證", backward: "被驗證" },
depends_on: { forward: "依賴", backward: "被依賴" },
tested_by: { forward: "被測試", backward: "測試" },
configures: { forward: "配置", backward: "被配置" },
related: { forward: "相關", backward: "相關" },
similar_to: { forward: "相似", backward: "相似" },
deploys: { forward: "部署", backward: "被部署" },
serves: { forward: "服務", backward: "被服務" },
migrates: { forward: "遷移", backward: "被遷移" },
documents: { forward: "文件化", backward: "被文件化" },
provisions: { forward: "提供", backward: "被提供" },
routes: { forward: "路由到", backward: "被路由" },
defines_schema: { forward: "定義架構", backward: "架構被定義" },
triggers: { forward: "觸發", backward: "被觸發" },
contains_flow: { forward: "包含流程", backward: "流程所在" },
flow_step: { forward: "流程步驟", backward: "步驟所属" },
cross_domain: { forward: "跨領域到", backward: "跨領域来自" },
cites: { forward: "引用", backward: "被引用" },
contradicts: { forward: "反駁", backward: "被反駁" },
builds_on: { forward: "基於", backward: "作為基礎" },
exemplifies: { forward: "例證", backward: "被例證" },
categorized_under: { forward: "归类於", backward: "归类" },
authored_by: { forward: "作者", backward: "著作" },
},
pathFinder: {
title: "尋找節點間路徑 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · 入門",
skipForever: "不再顯示",
prev: "上一步",
next: "下一步",
finish: "開始探索",
steps: [
{
title: "歡迎進入知識圖",
body: "你看到的圓點和連線是 Understand-Anything 把這份專案抽出來的實體和關係。節點可以是程式碼裡的檔案、類別、函式,也可以是知識 wiki 裡的概念、實體或斷言。",
hint: "5 步以內帶你過完核心操作",
},
{
title: "頂部三個視圖",
body: "Overview 看全貌(力導向圖)· Learn 跟隨預設學習路徑 · Deep Dive 看類型 / 複雜度統計。每個視圖回答一種不同的問法。",
hint: "切視圖前先想清楚自己在問什麼",
},
{
title: "搜尋 + 點節點",
body: "頂部搜尋框模糊匹配節點名 / summary / tags。點任意節點 → 右側詳情面板出現 summary + 鄰居列表 + Open Article 按鈕。",
hint: "搜尋高亮置中,點節點高亮鄰居邊",
},
{
title: "Layer 切換 + Tour",
body: "頂部 All 旁邊的 layer 標籤按 index.md 分類只顯示部分節點。右側 Project Tour 自動按編輯者預設順序導覽。",
hint: "節點太密看不清就用 Layer沒頭緒就啟 Tour",
},
{
title: "更多隱藏功能",
body: "頂欄還有 Filter按類型 / 複雜度過濾、Export匯出圖、Path找兩個節點之間的路徑、Theme切換主題。Shift + ? 看完整快捷鍵。",
hint: "需要時再展開,不要一次記完",
},
],
},
};
export default zhTW;

View File

@@ -0,0 +1,306 @@
export const zh = {
common: {
loading: "加载项目...",
noGraphLoaded: "未加载知识图谱",
selectNode: "选择节点查看详情",
back: "返回",
focus: "聚焦",
unfocus: "取消聚焦",
openCode: "打开代码",
file: "文件",
tags: "标签",
connections: "连接",
filter: "筛选",
resetAll: "重置全部",
analyzed: "分析时间",
startGuidedTour: "开始导览",
truncated: "(已截断)",
preview: "预览",
doubleClickToOpen: "双击打开",
appName: "Understand Anything",
pressKeyboard: "按 ? 查看键盘快捷键",
path: "路径",
theme: "主题",
},
projectOverview: {
nodes: "节点",
edges: "边",
layers: "层级",
types: "类型",
fileTypes: "文件类型",
code: "代码",
config: "配置",
docs: "文档",
infra: "基础设施",
data: "数据",
domain: "领域",
knowledge: "知识",
languages: "编程语言",
frameworks: "框架",
nodeTypeDistribution: "节点类型分布",
complexityDistribution: "复杂度分布",
simple: "简单",
moderate: "中等",
complex: "复杂",
mostConnectedNodes: "连接最多的节点",
avgConnectionsPerNode: "节点平均连接数",
},
nodeInfo: {
definedInThisFile: "在此文件中定义",
languageConcepts: "语言概念",
category: "分类",
wikilinks: "维基链接",
backlinks: "反向链接",
entities: "实体",
businessRules: "业务规则",
crossDomain: "跨领域",
flows: "流程",
entryPoint: "入口点",
steps: "步骤",
implementation: "实现",
},
fileExplorer: {
analyzedFiles: "已分析文件",
filesFromGraph: "来自当前知识图谱的文件",
noFilePathsFound: "未找到文件路径。",
},
filterPanel: {
nodeTypes: "节点类型",
complexity: "复杂度",
layers: "层级",
edgeCategories: "边类别",
},
personaSelector: {
overview: "概览",
overviewDesc: "高层次架构视图",
learn: "学习",
learnDesc: "完整仪表盘与导览学习",
deepDive: "深入",
deepDiveDesc: "代码聚焦与对话",
},
sidebar: {
info: "信息",
files: "文件",
},
mobile: {
graph: "图谱",
info: "信息",
files: "文件",
},
drawer: {
controls: "控制",
dashboard: "仪表盘",
role: "角色",
view: "视图",
diffOverlay: "差异覆盖",
nodeTypes: "节点类型",
layers: "层级",
tools: "工具",
path: "路径",
help: "帮助",
structural: "结构",
domain: "领域",
},
domainView: {
backToDomains: "返回领域列表",
},
detailLevel: {
filesTitle: "仅文件 — 架构级依赖(快速)",
classesTitle: "文件 + 类 — 代码结构及继承关系",
files: "文件",
classes: "+类",
fnTitle: "切换函数节点(可能降低渲染速度)",
fn: "函数",
},
nodeTypeLabels: {
all: "全部",
code: "代码",
config: "配置",
docs: "文档",
infra: "基础设施",
data: "数据",
domain: "领域",
knowledge: "知识",
},
tokenGate: {
validating: "验证中...",
continue: "继续",
},
diffToggle: {
hideOverlay: "隐藏差异覆盖",
showOverlay: "显示差异覆盖",
noData: "未加载差异数据",
changed: "已修改",
affected: "受影响",
},
learnPanel: {
finish: "完成",
next: "下一步",
prev: "上一步",
noTour: "无导览可用",
noTourHint: "从知识图谱生成导览以获取代码库的引导式讲解",
projectTour: "项目导览",
steps: "步",
stepsTitle: "步骤",
guidedWalkthrough: "代码库引导式讲解",
startTour: "开始导览",
tour: "导览",
exitTour: "退出导览",
},
layer: {
defaultName: "层级",
label: "层",
},
breadcrumb: {
projectOverview: "项目概览",
project: "项目",
escBack: "按 Esc 返回",
},
warningBanner: {
dropped: "已丢弃",
fatal: "致命错误",
},
themePicker: {
changeTheme: "更换主题",
theme: "主题",
accentColor: "强调色",
headingFont: "标题字体",
serif: "衬线",
sans: "无衬线",
mono: "等宽",
},
codeViewer: {
fullFile: "完整文件",
lines: "行",
linesLabel: "行",
noFile: "未选择文件",
loading: "加载源码中...",
openLarger: "打开更大的代码查看器",
closeExpanded: "关闭展开的代码查看器",
closeViewer: "关闭代码查看器",
sourceUnavailable: "源码不可用",
},
customNode: {
tested: "已测试",
hasTests: "有测试",
},
ariaLabels: {
openMenu: "打开菜单",
closeMenu: "关闭菜单",
settings: "设置",
hideSearch: "隐藏搜索",
showSearch: "显示搜索",
},
nodeTypeFilter: {
hide: "隐藏",
show: "显示",
nodesLabel: "节点",
},
keyboardShortcuts: {
showHelp: "显示键盘快捷键",
general: "通用",
navigation: "导航",
tour: "导览",
view: "视图",
focusSearch: "聚焦搜索栏",
nextStep: "下一步导览",
prevStep: "上一步导览",
toggleDiff: "切换差异模式",
toggleFilter: "切换筛选面板",
toggleExport: "切换导出菜单",
openPathFinder: "打开路径查找器",
title: "键盘快捷键",
toggleHint: "按 ? 随时切换此帮助",
closeHint: "按 ESC 关闭",
escapeDesc: "关闭面板和弹窗 / 返回概览",
},
search: {
placeholder: "搜索节点名称、摘要或标签...",
fuzzy: "模糊",
semantic: "语义",
result: "结果",
},
export: {
label: "导出",
title: "导出图谱 (E)",
asPNG: "导出为 PNG",
asSVG: "导出为 SVG",
asJSON: "导出为 JSON",
},
edgeLabels: {
imports: { forward: "导入", backward: "被导入" },
exports: { forward: "导出到", backward: "被导出" },
contains: { forward: "包含", backward: "被包含" },
inherits: { forward: "继承自", backward: "被继承" },
implements: { forward: "实现", backward: "被实现" },
calls: { forward: "调用", backward: "被调用" },
subscribes: { forward: "订阅", backward: "被订阅" },
publishes: { forward: "发布到", backward: "被消费" },
middleware: { forward: "中间件", backward: "使用中间件" },
reads_from: { forward: "读取", backward: "被读取" },
writes_to: { forward: "写入", backward: "被写入" },
transforms: { forward: "转换", backward: "被转换" },
validates: { forward: "验证", backward: "被验证" },
depends_on: { forward: "依赖", backward: "被依赖" },
tested_by: { forward: "被测试", backward: "测试" },
configures: { forward: "配置", backward: "被配置" },
related: { forward: "相关", backward: "相关" },
similar_to: { forward: "相似", backward: "相似" },
deploys: { forward: "部署", backward: "被部署" },
serves: { forward: "服务", backward: "被服务" },
migrates: { forward: "迁移", backward: "被迁移" },
documents: { forward: "文档化", backward: "被文档化" },
provisions: { forward: "提供", backward: "被提供" },
routes: { forward: "路由到", backward: "被路由" },
defines_schema: { forward: "定义架构", backward: "架构被定义" },
triggers: { forward: "触发", backward: "被触发" },
contains_flow: { forward: "包含流程", backward: "流程所在" },
flow_step: { forward: "流程步骤", backward: "步骤所属" },
cross_domain: { forward: "跨领域到", backward: "跨领域来自" },
cites: { forward: "引用", backward: "被引用" },
contradicts: { forward: "反驳", backward: "被反驳" },
builds_on: { forward: "基于", backward: "作为基础" },
exemplifies: { forward: "例证", backward: "被例证" },
categorized_under: { forward: "归类于", backward: "归类" },
authored_by: { forward: "作者", backward: "著作" },
},
pathFinder: {
title: "查找节点间路径 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · 入门",
skipForever: "不再显示",
prev: "上一步",
next: "下一步",
finish: "开始探索",
steps: [
{
title: "欢迎进入知识图",
body: "你看到的圆点和连线是 Understand-Anything 把这份项目抽出来的实体和关系。节点可以是代码里的文件、类、函数,也可以是知识 wiki 里的概念、实体或断言。",
hint: "5 步以内带你过完核心操作",
},
{
title: "顶部三个视图",
body: "Overview 看全貌(力导向图)· Learn 跟随预设学习路径 · Deep Dive 看类型 / 复杂度统计。每个视图回答一种不同的问法。",
hint: "切视图前先想清楚自己在问什么",
},
{
title: "搜索 + 点节点",
body: "顶部搜索框模糊匹配节点名 / summary / tags。点任意节点 → 右侧详情面板出现 summary + 邻居列表 + Open Article 按钮。",
hint: "搜索高亮居中,点节点高亮邻居边",
},
{
title: "Layer 切换 + Tour",
body: "顶部 All 旁边的 layer 标签按 index.md 分类只显示部分节点。右侧 Project Tour 自动按编辑者预设顺序导览。",
hint: "节点太密看不清就用 Layer没头绪就启 Tour",
},
{
title: "更多隐藏功能",
body: "顶栏还有 Filter按类型 / 复杂度过滤、Export导出图、Path找两个节点之间的路径、Theme切换主题。Shift + ? 看完整快捷键。",
hint: "需要时再展开,不要一次记完",
},
],
},
};
export default zh;

View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,784 @@
import { create } from "zustand";
import { SearchEngine } from "@understand-anything/core/search";
import type { SearchResult } from "@understand-anything/core/search";
import type { GraphIssue } from "@understand-anything/core/schema";
import type {
GraphNode,
KnowledgeGraph,
TourStep,
} from "@understand-anything/core/types";
import type { ReactFlowInstance } from "@xyflow/react";
export type Persona = "non-technical" | "junior" | "experienced";
export type NavigationLevel = "overview" | "layer-detail";
export type NodeType = "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource" | "domain" | "flow" | "step" | "article" | "entity" | "topic" | "claim" | "source";
export type Complexity = "simple" | "moderate" | "complex";
export type EdgeCategory = "structural" | "behavioral" | "data-flow" | "dependencies" | "semantic" | "infrastructure" | "domain" | "knowledge";
export type ViewMode = "structural" | "domain" | "knowledge";
export type DetailLevel = "file" | "class";
export interface FilterState {
nodeTypes: Set<NodeType>;
complexities: Set<Complexity>;
layerIds: Set<string>;
edgeCategories: Set<EdgeCategory>;
}
export const ALL_NODE_TYPES: NodeType[] = ["file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource", "domain", "flow", "step", "article", "entity", "topic", "claim", "source"];
export const ALL_COMPLEXITIES: Complexity[] = ["simple", "moderate", "complex"];
export const ALL_EDGE_CATEGORIES: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic", "infrastructure", "domain", "knowledge"];
export const EDGE_CATEGORY_MAP: Record<EdgeCategory, string[]> = {
structural: ["imports", "exports", "contains", "inherits", "implements"],
behavioral: ["calls", "subscribes", "publishes", "middleware"],
"data-flow": ["reads_from", "writes_to", "transforms", "validates"],
dependencies: ["depends_on", "tested_by", "configures"],
semantic: ["related", "similar_to"],
infrastructure: ["deploys", "serves", "provisions", "triggers", "migrates", "documents", "routes", "defines_schema"],
domain: ["contains_flow", "flow_step", "cross_domain"],
knowledge: ["cites", "contradicts", "builds_on", "exemplifies", "categorized_under", "authored_by"],
};
export const DOMAIN_EDGE_TYPES = EDGE_CATEGORY_MAP.domain;
const DEFAULT_FILTERS: FilterState = {
nodeTypes: new Set<NodeType>(ALL_NODE_TYPES),
complexities: new Set<Complexity>(ALL_COMPLEXITIES),
layerIds: new Set<string>(),
edgeCategories: new Set<EdgeCategory>(ALL_EDGE_CATEGORIES),
};
/** Categories used for node type filter toggles. Single source of truth for NodeCategory. */
export type NodeCategory = "code" | "config" | "docs" | "infra" | "data" | "domain" | "knowledge";
/**
* Build the (id → node) and (id → layerId) lookup maps that the rest of
* the dashboard reads via store selectors. Centralised so `setGraph` and
* any future graph-replacement path stay in sync.
*
* Two layer indexes, intentionally distinct:
*
* - `nodeIdToLayerId` preserves the prior `findNodeLayer` "first matching
* layer wins" semantics — if a node id appears in multiple layers
* (rare but legal in the schema), the first occurrence in `graph.layers`
* order is the one we map to. Drives navigation (drillIntoLayer, tour
* step → layer, sidebar history) where a single canonical layer is the
* right answer.
*
* - `nodeIdToLayerIds` records *every* layer a node belongs to. Drives
* membership queries (filterNodes) where the prior `Layer[] +
* layer.nodeIds.includes` shape was any-layer-wins — a node in L1 and
* L2 with only L2 selected must still pass. Collapsing to first-wins
* for filtering would be a silent regression.
*/
function buildGraphIndexes(graph: KnowledgeGraph): {
nodesById: Map<string, GraphNode>;
nodeIdToLayerId: Map<string, string>;
nodeIdToLayerIds: Map<string, Set<string>>;
} {
const nodesById = new Map<string, GraphNode>();
for (const node of graph.nodes) nodesById.set(node.id, node);
const nodeIdToLayerId = new Map<string, string>();
const nodeIdToLayerIds = new Map<string, Set<string>>();
for (const layer of graph.layers) {
for (const nid of layer.nodeIds) {
if (!nodeIdToLayerId.has(nid)) nodeIdToLayerId.set(nid, layer.id);
let set = nodeIdToLayerIds.get(nid);
if (!set) {
set = new Set<string>();
nodeIdToLayerIds.set(nid, set);
}
set.add(layer.id);
}
}
return { nodesById, nodeIdToLayerId, nodeIdToLayerIds };
}
/** Maximum number of entries in the sidebar navigation history. */
const MAX_HISTORY = 50;
interface DashboardStore {
graph: KnowledgeGraph | null;
/** id → node lookup, rebuilt by setGraph. Empty before any graph loads. */
nodesById: Map<string, GraphNode>;
/** id → layer id (first-matching-layer wins), rebuilt by setGraph. Empty before any graph loads. */
nodeIdToLayerId: Map<string, string>;
/** id → set of every layer the node belongs to, rebuilt by setGraph. Empty before any graph loads. */
nodeIdToLayerIds: Map<string, Set<string>>;
selectedNodeId: string | null;
searchQuery: string;
searchResults: SearchResult[];
searchEngine: SearchEngine | null;
searchMode: "fuzzy" | "semantic";
setSearchMode: (mode: "fuzzy" | "semantic") => void;
// Lens navigation
navigationLevel: NavigationLevel;
activeLayerId: string | null;
codeViewerOpen: boolean;
codeViewerNodeId: string | null;
codeViewerExpanded: boolean;
tourActive: boolean;
currentTourStep: number;
tourHighlightedNodeIds: string[];
persona: Persona;
diffMode: boolean;
changedNodeIds: Set<string>;
affectedNodeIds: Set<string>;
// Focus mode: isolate a node's 1-hop neighborhood
focusNodeId: string | null;
// Sidebar navigation history (stack of visited node IDs)
nodeHistory: string[];
// Filter & Export features
filters: FilterState;
filterPanelOpen: boolean;
exportMenuOpen: boolean;
pathFinderOpen: boolean;
reactFlowInstance: ReactFlowInstance | null;
// Node type category filters
nodeTypeFilters: Record<NodeCategory, boolean>;
toggleNodeTypeFilter: (category: NodeCategory) => void;
// Detail level: "file" shows only file nodes (architecture view),
// "class" shows files + class nodes (code structure view) with optional function expansion.
detailLevel: DetailLevel;
setDetailLevel: (level: DetailLevel) => void;
showFunctionsInClassView: boolean;
toggleShowFunctionsInClassView: () => void;
setGraph: (graph: KnowledgeGraph) => void;
selectNode: (nodeId: string | null) => void;
navigateToNode: (nodeId: string) => void;
navigateToNodeInLayer: (nodeId: string) => void;
navigateToHistoryIndex: (index: number) => void;
goBackNode: () => void;
drillIntoLayer: (layerId: string) => void;
navigateToOverview: () => void;
setFocusNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
setPersona: (persona: Persona) => void;
openCodeViewer: (nodeId: string) => void;
closeCodeViewer: () => void;
expandCodeViewer: () => void;
collapseCodeViewer: () => void;
setDiffOverlay: (changed: string[], affected: string[]) => void;
toggleDiffMode: () => void;
clearDiffOverlay: () => void;
toggleFilterPanel: () => void;
toggleExportMenu: () => void;
togglePathFinder: () => void;
setReactFlowInstance: (instance: ReactFlowInstance | null) => void;
setFilters: (filters: Partial<FilterState>) => void;
resetFilters: () => void;
hasActiveFilters: () => boolean;
startTour: () => void;
stopTour: () => void;
setTourStep: (step: number) => void;
nextTourStep: () => void;
prevTourStep: () => void;
// View mode
viewMode: ViewMode;
isKnowledgeGraph: boolean;
domainGraph: KnowledgeGraph | null;
activeDomainId: string | null;
setDomainGraph: (graph: KnowledgeGraph) => void;
setViewMode: (mode: ViewMode) => void;
setIsKnowledgeGraph: (value: boolean) => void;
navigateToDomain: (domainId: string) => void;
clearActiveDomain: () => void;
// Container expand/collapse + lazy layout caches
expandedContainers: Set<string>;
toggleContainer: (containerId: string) => void;
expandContainer: (containerId: string) => void;
collapseContainer: (containerId: string) => void;
collapseAllContainers: () => void;
/** Container the user just manually expanded; viewport should lock onto it. Cleared by GraphView once the lock is applied. */
pendingFocusContainer: string | null;
setPendingFocusContainer: (containerId: string | null) => void;
/** True while TourFitView is waiting for highlighted nodes to materialise (Stage 2 layout in progress). Drives the "Computing layout…" overlay. */
tourFitPending: boolean;
setTourFitPending: (pending: boolean) => void;
containerLayoutCache: Map<
string,
{
childPositions: Map<string, { x: number; y: number }>;
actualSize: { width: number; height: number };
}
>;
setContainerLayout: (
containerId: string,
childPositions: Map<string, { x: number; y: number }>,
actualSize: { width: number; height: number },
) => void;
clearContainerLayouts: () => void;
containerSizeMemory: Map<string, { width: number; height: number }>;
stage1Tick: number;
bumpStage1Tick: () => void;
// Layout-time issues (e.g. ELK input repair). Funneled into the
// WarningBanner alongside graph-validation issues.
layoutIssues: GraphIssue[];
appendLayoutIssues: (issues: GraphIssue[]) => void;
clearLayoutIssues: () => void;
}
function getSortedTour(graph: KnowledgeGraph): TourStep[] {
const tour = graph.tour ?? [];
return [...tour].sort((a, b) => a.order - b.order);
}
/** Navigate tour step to the correct layer for the first highlighted node. */
function navigateTourToLayer(
nodeIdToLayerId: Map<string, string>,
nodeIds: string[],
): Partial<DashboardStore> {
if (nodeIds.length === 0) return {};
const layerId = nodeIdToLayerId.get(nodeIds[0]);
if (layerId) {
return {
navigationLevel: "layer-detail" as const,
activeLayerId: layerId,
};
}
return {};
}
/**
* Container ids derive from per-layer state — folder names in folder-strategy
* layers, community indices (`container:cluster-N`) in community-strategy
* layers — and collide across layers (e.g. API Contracts and Load Testing
* both produce `container:cluster-0`). When a tour step crosses layers we
* must drop the previous layer's container caches so Stage 2 actually re-
* runs for the new layer's children. Mirrors the reset block in
* `drillIntoLayer`.
*/
function layerResetIfChanged(
layerNav: Partial<DashboardStore>,
prevLayerId: string | null,
): Partial<DashboardStore> {
const next = layerNav.activeLayerId;
if (!next || next === prevLayerId) return {};
return {
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
// Drop any pending focus too — its id was scoped to the previous
// layer and would otherwise re-collide with a same-id container in
// the new layer for the duration of the 1.2s timer.
pendingFocusContainer: null,
};
}
export const useDashboardStore = create<DashboardStore>()((set, get) => ({
graph: null,
nodesById: new Map<string, GraphNode>(),
nodeIdToLayerId: new Map<string, string>(),
nodeIdToLayerIds: new Map<string, Set<string>>(),
selectedNodeId: null,
searchQuery: "",
searchResults: [],
searchEngine: null,
searchMode: "fuzzy",
navigationLevel: "overview",
activeLayerId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
tourActive: false,
currentTourStep: 0,
tourHighlightedNodeIds: [],
persona: "junior",
diffMode: false,
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
focusNodeId: null,
nodeHistory: [],
filters: { ...DEFAULT_FILTERS, nodeTypes: new Set(DEFAULT_FILTERS.nodeTypes), complexities: new Set(DEFAULT_FILTERS.complexities), layerIds: new Set(DEFAULT_FILTERS.layerIds), edgeCategories: new Set(DEFAULT_FILTERS.edgeCategories) },
filterPanelOpen: false,
exportMenuOpen: false,
pathFinderOpen: false,
reactFlowInstance: null,
nodeTypeFilters: { code: true, config: true, docs: true, infra: true, data: true, domain: true, knowledge: true },
toggleNodeTypeFilter: (category) =>
set((state) => ({
nodeTypeFilters: {
...state.nodeTypeFilters,
[category]: !state.nodeTypeFilters[category],
},
// Filter changes shift container.nodeIds; cached child positions
// may reference filtered-out children. Drop the cache so Stage 2
// recomputes against the current set.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
})),
detailLevel: "file",
setDetailLevel: (level) =>
set({
detailLevel: level,
// Detail level changes which nodes are visible; cached positions stale.
// Reset fn toggle so it doesn't resurrect when re-entering class view.
showFunctionsInClassView: false,
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
showFunctionsInClassView: false,
toggleShowFunctionsInClassView: () =>
set((state) => ({
showFunctionsInClassView: !state.showFunctionsInClassView,
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
})),
setGraph: (graph) => {
const searchEngine = new SearchEngine(graph.nodes);
const query = get().searchQuery;
const searchResults = query.trim() ? searchEngine.search(query) : [];
const { viewMode, domainGraph, activeDomainId } = get();
// Preserve domain view if a domain graph is already loaded
const keepDomainView = viewMode === "domain" && domainGraph !== null;
const { nodesById, nodeIdToLayerId, nodeIdToLayerIds } = buildGraphIndexes(graph);
set({
graph,
nodesById,
nodeIdToLayerId,
nodeIdToLayerIds,
searchEngine,
searchResults,
navigationLevel: "overview",
activeLayerId: null,
selectedNodeId: null,
focusNodeId: null,
nodeHistory: [],
viewMode: keepDomainView ? "domain" as const : "structural" as const,
activeDomainId: keepDomainView ? activeDomainId : null,
containerLayoutCache: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
containerSizeMemory: new Map(),
stage1Tick: 0,
layoutIssues: [],
});
},
selectNode: (nodeId) => {
const { selectedNodeId, nodeHistory } = get();
if (nodeId && selectedNodeId && nodeId !== selectedNodeId) {
// Push current node to history before navigating away
set({
selectedNodeId: nodeId,
nodeHistory: [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY),
});
} else {
set({ selectedNodeId: nodeId });
}
},
navigateToNode: (nodeId) => {
get().navigateToNodeInLayer(nodeId);
},
navigateToNodeInLayer: (nodeId) => {
const { graph, selectedNodeId, nodeHistory, nodeIdToLayerId } = get();
if (!graph) return;
const layerId = nodeIdToLayerId.get(nodeId) ?? null;
const newHistory =
selectedNodeId && nodeId !== selectedNodeId
? [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY)
: nodeHistory;
if (layerId) {
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
selectedNodeId: nodeId,
focusNodeId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
nodeHistory: newHistory,
});
} else {
set({
selectedNodeId: nodeId,
nodeHistory: newHistory,
});
}
},
navigateToHistoryIndex: (index) => {
const { nodeHistory, graph, nodeIdToLayerId } = get();
if (!graph || index < 0 || index >= nodeHistory.length) return;
const targetId = nodeHistory[index];
const newHistory = nodeHistory.slice(0, index);
const layerId = nodeIdToLayerId.get(targetId) ?? null;
set({
selectedNodeId: targetId,
nodeHistory: newHistory,
...(layerId ? { navigationLevel: "layer-detail" as const, activeLayerId: layerId } : {}),
});
},
goBackNode: () => {
const { nodeHistory, graph, nodeIdToLayerId } = get();
if (nodeHistory.length === 0 || !graph) return;
const prevNodeId = nodeHistory[nodeHistory.length - 1];
const newHistory = nodeHistory.slice(0, -1);
const layerId = nodeIdToLayerId.get(prevNodeId) ?? null;
if (layerId) {
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
selectedNodeId: prevNodeId,
nodeHistory: newHistory,
});
} else {
set({
selectedNodeId: prevNodeId,
nodeHistory: newHistory,
});
}
},
drillIntoLayer: (layerId) =>
set({
navigationLevel: "layer-detail",
activeLayerId: layerId,
selectedNodeId: null,
focusNodeId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
// Container ids derive from folder names and collide across layers
// (e.g. `container:auth` exists in many layers). Drop the cache so
// we don't render stale positions for the new layer's children.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
navigateToOverview: () =>
set({
navigationLevel: "overview",
activeLayerId: null,
selectedNodeId: null,
focusNodeId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
setFocusNode: (nodeId) =>
set({
focusNodeId: nodeId,
selectedNodeId: nodeId,
// Focus mode narrows filteredGraphNodes to focus + 1-hop; the
// surviving containers have a subset of their original children,
// and the cache must not return positions for filtered-out ids.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
setSearchMode: (mode) => set({ searchMode: mode }),
setSearchQuery: (query) => {
const engine = get().searchEngine;
const mode = get().searchMode;
if (!engine || !query.trim()) {
set({ searchQuery: query, searchResults: [] });
return;
}
// Currently both modes use the same fuzzy engine
// When embeddings are available, "semantic" mode will use SemanticSearchEngine
void mode;
const searchResults = engine.search(query);
set({ searchQuery: query, searchResults });
},
setPersona: (persona) =>
set({
persona,
// Persona changes filter node types, which shifts container.nodeIds.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
openCodeViewer: (nodeId) =>
set({ codeViewerOpen: true, codeViewerNodeId: nodeId, codeViewerExpanded: false }),
closeCodeViewer: () =>
set({ codeViewerOpen: false, codeViewerNodeId: null, codeViewerExpanded: false }),
expandCodeViewer: () => set({ codeViewerExpanded: true }),
collapseCodeViewer: () => set({ codeViewerExpanded: false }),
setDiffOverlay: (changed, affected) =>
set({
diffMode: true,
changedNodeIds: new Set(changed),
affectedNodeIds: new Set(affected),
}),
toggleDiffMode: () => set((state) => ({ diffMode: !state.diffMode })),
clearDiffOverlay: () =>
set({
diffMode: false,
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
}),
toggleFilterPanel: () => set((state) => ({
filterPanelOpen: !state.filterPanelOpen,
exportMenuOpen: false,
})),
toggleExportMenu: () => set((state) => ({
exportMenuOpen: !state.exportMenuOpen,
filterPanelOpen: false,
})),
togglePathFinder: () => set((state) => ({
pathFinderOpen: !state.pathFinderOpen,
})),
setReactFlowInstance: (instance) => set({ reactFlowInstance: instance }),
setFilters: (newFilters) => set((state) => ({
filters: { ...state.filters, ...newFilters },
})),
resetFilters: () => set({
filters: {
nodeTypes: new Set<NodeType>(ALL_NODE_TYPES),
complexities: new Set<Complexity>(ALL_COMPLEXITIES),
layerIds: new Set<string>(),
edgeCategories: new Set<EdgeCategory>(ALL_EDGE_CATEGORIES),
},
}),
hasActiveFilters: () => {
const { filters } = get();
return filters.nodeTypes.size !== ALL_NODE_TYPES.length
|| filters.complexities.size !== ALL_COMPLEXITIES.length
|| filters.layerIds.size > 0
|| filters.edgeCategories.size !== ALL_EDGE_CATEGORIES.length;
},
startTour: () => {
const { graph, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
const sorted = getSortedTour(graph);
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[0].nodeIds);
set({
tourActive: true,
currentTourStep: 0,
tourHighlightedNodeIds: sorted[0].nodeIds,
selectedNodeId: null,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
},
stopTour: () =>
set({
tourActive: false,
currentTourStep: 0,
tourHighlightedNodeIds: [],
}),
setTourStep: (step) => {
const { graph, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
const sorted = getSortedTour(graph);
if (step < 0 || step >= sorted.length) return;
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[step].nodeIds);
set({
currentTourStep: step,
tourHighlightedNodeIds: sorted[step].nodeIds,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
},
nextTourStep: () => {
const { graph, currentTourStep, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
const sorted = getSortedTour(graph);
if (currentTourStep < sorted.length - 1) {
const next = currentTourStep + 1;
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[next].nodeIds);
set({
currentTourStep: next,
tourHighlightedNodeIds: sorted[next].nodeIds,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
}
},
prevTourStep: () => {
const { graph, currentTourStep, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
if (currentTourStep > 0) {
const sorted = getSortedTour(graph);
const prev = currentTourStep - 1;
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[prev].nodeIds);
set({
currentTourStep: prev,
tourHighlightedNodeIds: sorted[prev].nodeIds,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
}
},
viewMode: "structural",
isKnowledgeGraph: false,
domainGraph: null,
activeDomainId: null,
setDomainGraph: (graph) => {
set({ domainGraph: graph });
},
setIsKnowledgeGraph: (value) => {
set({ isKnowledgeGraph: value });
},
setViewMode: (mode) => {
set({
viewMode: mode,
selectedNodeId: null,
focusNodeId: null,
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
});
},
navigateToDomain: (domainId) => {
const { selectedNodeId, nodeHistory } = get();
const newHistory = selectedNodeId
? [...nodeHistory, selectedNodeId].slice(-MAX_HISTORY)
: nodeHistory;
set({
viewMode: "domain" as const,
activeDomainId: domainId,
focusNodeId: null,
nodeHistory: newHistory,
});
},
clearActiveDomain: () => {
set({
activeDomainId: null,
selectedNodeId: null,
focusNodeId: null,
});
},
expandedContainers: new Set<string>(),
pendingFocusContainer: null,
setPendingFocusContainer: (containerId) =>
set({ pendingFocusContainer: containerId }),
tourFitPending: false,
setTourFitPending: (pending) => set({ tourFitPending: pending }),
toggleContainer: (containerId) =>
set((state) => {
const next = new Set(state.expandedContainers);
const willExpand = !next.has(containerId);
if (willExpand) next.add(containerId);
else next.delete(containerId);
return {
expandedContainers: next,
pendingFocusContainer: willExpand
? containerId
: state.pendingFocusContainer,
};
}),
expandContainer: (containerId) =>
set((state) => {
if (state.expandedContainers.has(containerId)) return {};
const next = new Set(state.expandedContainers);
next.add(containerId);
return { expandedContainers: next };
}),
collapseContainer: (containerId) =>
set((state) => {
if (!state.expandedContainers.has(containerId)) return {};
const next = new Set(state.expandedContainers);
next.delete(containerId);
return { expandedContainers: next };
}),
collapseAllContainers: () => set({ expandedContainers: new Set() }),
containerLayoutCache: new Map(),
setContainerLayout: (containerId, childPositions, actualSize) =>
set((state) => {
const next = new Map(state.containerLayoutCache);
next.set(containerId, { childPositions, actualSize });
const sizeNext = new Map(state.containerSizeMemory);
sizeNext.set(containerId, actualSize);
return { containerLayoutCache: next, containerSizeMemory: sizeNext };
}),
clearContainerLayouts: () =>
set({ containerLayoutCache: new Map(), expandedContainers: new Set(), pendingFocusContainer: null }),
containerSizeMemory: new Map(),
stage1Tick: 0,
bumpStage1Tick: () => set((s) => ({ stage1Tick: s.stage1Tick + 1 })),
layoutIssues: [],
appendLayoutIssues: (issues) =>
set((state) => {
if (issues.length === 0) return {};
// Dedupe by level+message so a re-running effect doesn't repeatedly
// pile up identical issues.
const seen = new Set(
state.layoutIssues.map((i) => `${i.level}|${i.message}`),
);
const fresh = issues.filter((i) => !seen.has(`${i.level}|${i.message}`));
if (fresh.length === 0) return {};
return { layoutIssues: [...state.layoutIssues, ...fresh] };
}),
clearLayoutIssues: () => set({ layoutIssues: [] }),
}));

View File

@@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import type { HeadingFont, PresetId, ThemeConfig, ThemePreset } from "./types.ts";
import { DEFAULT_THEME_CONFIG } from "./types.ts";
import { getPreset } from "./presets.ts";
import { applyTheme } from "./theme-engine.ts";
const STORAGE_KEY = "ua-theme";
interface ThemeContextValue {
config: ThemeConfig;
preset: ThemePreset;
setPreset: (presetId: PresetId) => void;
setAccent: (accentId: string) => void;
setHeadingFont: (font: HeadingFont) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function loadFromLocalStorage(): ThemeConfig | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.presetId === "string" && typeof parsed.accentId === "string") {
return parsed as ThemeConfig;
}
return null;
} catch {
return null;
}
}
function saveToLocalStorage(config: ThemeConfig): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// Storage full or unavailable — ignore
}
}
function resolveInitialTheme(metaTheme?: ThemeConfig | null): ThemeConfig {
return loadFromLocalStorage() ?? metaTheme ?? DEFAULT_THEME_CONFIG;
}
interface ThemeProviderProps {
metaTheme?: ThemeConfig | null;
children: ReactNode;
}
export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) {
const [config, setConfig] = useState<ThemeConfig>(() => resolveInitialTheme(metaTheme));
const initialized = useRef(false);
// Apply theme on mount and config changes
useEffect(() => {
applyTheme(config);
if (initialized.current) {
saveToLocalStorage(config);
}
initialized.current = true;
}, [config]);
// Update if metaTheme arrives later (async fetch) and no localStorage preference exists
useEffect(() => {
if (metaTheme && !loadFromLocalStorage()) {
setConfig(metaTheme);
}
}, [metaTheme]);
const setPreset = useCallback((presetId: PresetId) => {
setConfig((_prev) => {
const newPreset = getPreset(presetId);
return { presetId, accentId: newPreset.defaultAccentId };
});
}, []);
const setAccent = useCallback((accentId: string) => {
setConfig((prev) => ({ ...prev, accentId }));
}, []);
const setHeadingFont = useCallback((font: HeadingFont) => {
setConfig((prev) => ({ ...prev, headingFont: font }));
}, []);
const preset = getPreset(config.presetId);
return (
<ThemeContext.Provider value={{ config, preset, setPreset, setAccent, setHeadingFont }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,5 @@
export { ThemeProvider, useTheme } from "./ThemeContext.tsx";
export { PRESETS, getPreset, getAccent } from "./presets.ts";
export { applyTheme } from "./theme-engine.ts";
export type { HeadingFont, PresetId, ThemeConfig, ThemePreset, AccentSwatch } from "./types.ts";
export { DEFAULT_THEME_CONFIG } from "./types.ts";

View File

@@ -0,0 +1,183 @@
import type { AccentSwatch, ThemePreset } from "./types.ts";
const DARK_ACCENT_SWATCHES: AccentSwatch[] = [
{ id: "gold", name: "Gold", accent: "#d4a574", accentDim: "#c9a96e", accentBright: "#e8c49a" },
{ id: "ocean", name: "Ocean", accent: "#5ba4cf", accentDim: "#4e93ba", accentBright: "#7abce0" },
{ id: "emerald", name: "Emerald", accent: "#5ea67a", accentDim: "#4e9468", accentBright: "#78c492" },
{ id: "rose", name: "Rose", accent: "#cf7a8a", accentDim: "#b96e7e", accentBright: "#e094a4" },
{ id: "purple", name: "Purple", accent: "#9b7abf", accentDim: "#876bb0", accentBright: "#b494d4" },
{ id: "amber", name: "Amber", accent: "#c9963a", accentDim: "#b5862e", accentBright: "#ddb05c" },
{ id: "teal", name: "Teal", accent: "#4aab9a", accentDim: "#3d9686", accentBright: "#68c4b4" },
{ id: "silver", name: "Silver", accent: "#a0a8b0", accentDim: "#8e959c", accentBright: "#b8bfc6" },
];
const LIGHT_ACCENT_SWATCHES: AccentSwatch[] = [
{ id: "indigo", name: "Indigo", accent: "#4a6fa5", accentDim: "#3d5f8f", accentBright: "#6088bf" },
{ id: "ocean", name: "Ocean", accent: "#3a8ab5", accentDim: "#2e7aa0", accentBright: "#55a0cc" },
{ id: "emerald", name: "Emerald", accent: "#3a8a5c", accentDim: "#2e7a4e", accentBright: "#55a878" },
{ id: "rose", name: "Rose", accent: "#a5566a", accentDim: "#8f4a5c", accentBright: "#bf6e82" },
{ id: "purple", name: "Purple", accent: "#6b5a9e", accentDim: "#5c4d8a", accentBright: "#8474b5" },
{ id: "amber", name: "Amber", accent: "#9e7a30", accentDim: "#8a6a28", accentBright: "#b5923e" },
{ id: "teal", name: "Teal", accent: "#2e8a7a", accentDim: "#267a6c", accentBright: "#45a595" },
{ id: "slate", name: "Slate", accent: "#5a6570", accentDim: "#4e5860", accentBright: "#6e7a85" },
];
export const PRESETS: ThemePreset[] = [
{
id: "dark-gold",
name: "Dark Gold",
isDark: true,
defaultAccentId: "gold",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#0a0a0a",
surface: "#111111",
elevated: "#1a1a1a",
panel: "#141414",
"text-primary": "#f5f0eb",
"text-secondary": "#a39787",
"text-muted": "#6b5f53",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
"node-config": "#5eead4",
"node-document": "#7dd3fc",
"node-service": "#a78bfa",
"node-table": "#6ee7b7",
"node-endpoint": "#fdba74",
"node-pipeline": "#fda4af",
"node-schema": "#fcd34d",
"node-resource": "#a5b4fc",
},
},
{
id: "dark-ocean",
name: "Dark Ocean",
isDark: true,
defaultAccentId: "ocean",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#0a0e14",
surface: "#111820",
elevated: "#1a222c",
panel: "#141c24",
"text-primary": "#e8edf2",
"text-secondary": "#87939f",
"text-muted": "#536b7a",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
"node-config": "#5eead4",
"node-document": "#7dd3fc",
"node-service": "#a78bfa",
"node-table": "#6ee7b7",
"node-endpoint": "#fdba74",
"node-pipeline": "#fda4af",
"node-schema": "#fcd34d",
"node-resource": "#a5b4fc",
},
},
{
id: "dark-forest",
name: "Dark Forest",
isDark: true,
defaultAccentId: "emerald",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#0a100a",
surface: "#111811",
elevated: "#1a241a",
panel: "#141c14",
"text-primary": "#ebf0eb",
"text-secondary": "#87a38f",
"text-muted": "#536b5a",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
"node-config": "#5eead4",
"node-document": "#7dd3fc",
"node-service": "#a78bfa",
"node-table": "#6ee7b7",
"node-endpoint": "#fdba74",
"node-pipeline": "#fda4af",
"node-schema": "#fcd34d",
"node-resource": "#a5b4fc",
},
},
{
id: "dark-rose",
name: "Dark Rose",
isDark: true,
defaultAccentId: "rose",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#100a0a",
surface: "#181111",
elevated: "#221a1a",
panel: "#1c1414",
"text-primary": "#f2e8ea",
"text-secondary": "#9f8790",
"text-muted": "#6b535a",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
"node-config": "#5eead4",
"node-document": "#7dd3fc",
"node-service": "#a78bfa",
"node-table": "#6ee7b7",
"node-endpoint": "#fdba74",
"node-pipeline": "#fda4af",
"node-schema": "#fcd34d",
"node-resource": "#a5b4fc",
},
},
{
id: "light-minimal",
name: "Light Minimal",
isDark: false,
defaultAccentId: "indigo",
accentSwatches: LIGHT_ACCENT_SWATCHES,
colors: {
root: "#f5f3f0",
surface: "#eae7e3",
elevated: "#ffffff",
panel: "#f0ede9",
"text-primary": "#1a1a1a",
"text-secondary": "#6b6b6b",
"text-muted": "#a0a0a0",
"node-file": "#3a6a87",
"node-function": "#488a5b",
"node-class": "#755d99",
"node-module": "#a88a56",
"node-concept": "#966674",
"node-config": "#14b8a6",
"node-document": "#38bdf8",
"node-service": "#8b5cf6",
"node-table": "#34d399",
"node-endpoint": "#fb923c",
"node-pipeline": "#fb7185",
"node-schema": "#facc15",
"node-resource": "#818cf8",
},
},
];
export function getPreset(id: string): ThemePreset {
return PRESETS.find((p) => p.id === id) ?? PRESETS[0];
}
export function getAccent(preset: ThemePreset, accentId: string): AccentSwatch {
return (
preset.accentSwatches.find((s) => s.id === accentId) ??
preset.accentSwatches.find((s) => s.id === preset.defaultAccentId) ??
preset.accentSwatches[0]
);
}

View File

@@ -0,0 +1,65 @@
import type { ThemeConfig } from "./types.ts";
import { getAccent, getPreset } from "./presets.ts";
export function hexToRgb(hex: string): string {
const h = hex.replace("#", "");
const n = parseInt(h, 16);
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
}
function deriveFromAccent(accentHex: string, isDark: boolean): Record<string, string> {
const rgb = hexToRgb(accentHex);
return {
"color-border-subtle": `rgba(${rgb}, ${isDark ? 0.12 : 0.1})`,
"color-border-medium": `rgba(${rgb}, ${isDark ? 0.25 : 0.18})`,
"glass-bg": isDark ? "rgba(20, 20, 20, 0.8)" : "rgba(255, 255, 255, 0.8)",
"glass-bg-heavy": isDark ? "rgba(20, 20, 20, 0.95)" : "rgba(255, 255, 255, 0.95)",
"glass-border": `rgba(${rgb}, ${isDark ? 0.1 : 0.08})`,
"glass-border-heavy": `rgba(${rgb}, ${isDark ? 0.15 : 0.12})`,
"scrollbar-thumb": `rgba(${rgb}, 0.2)`,
"scrollbar-thumb-hover": `rgba(${rgb}, 0.35)`,
"glow-accent": `rgba(${rgb}, 0.15)`,
"glow-accent-strong": `rgba(${rgb}, 0.4)`,
"glow-accent-pulse": `rgba(${rgb}, 0.6)`,
"color-edge": `rgba(${rgb}, 0.3)`,
"color-edge-dim": `rgba(${rgb}, 0.08)`,
"color-edge-dot": `rgba(${rgb}, 0.15)`,
"color-accent-overlay-bg": `rgba(${rgb}, 0.05)`,
"color-accent-overlay-border": `rgba(${rgb}, 0.25)`,
"kbd-bg": `rgba(${rgb}, 0.1)`,
};
}
export function applyTheme(config: ThemeConfig): void {
const preset = getPreset(config.presetId);
const accent = getAccent(preset, config.accentId);
const style = document.documentElement.style;
// 1. Apply base preset colors
for (const [key, value] of Object.entries(preset.colors)) {
style.setProperty(`--color-${key}`, value);
}
// 2. Apply accent colors from swatch
style.setProperty("--color-accent", accent.accent);
style.setProperty("--color-accent-dim", accent.accentDim);
style.setProperty("--color-accent-bright", accent.accentBright);
// 3. Apply derived values
const derived = deriveFromAccent(accent.accent, preset.isDark);
for (const [key, value] of Object.entries(derived)) {
style.setProperty(`--${key}`, value);
}
// 4. Set data-theme for CSS-only selectors
document.documentElement.setAttribute("data-theme", preset.isDark ? "dark" : "light");
// 5. Apply heading font preference
const fontMap: Record<string, string> = {
serif: "var(--font-serif)",
sans: "var(--font-sans)",
mono: "var(--font-mono)",
};
const headingFont = config.headingFont ?? "serif";
style.setProperty("--font-heading", fontMap[headingFont] ?? fontMap.serif);
}

View File

@@ -0,0 +1,36 @@
export type PresetId =
| "dark-gold"
| "dark-ocean"
| "dark-forest"
| "dark-rose"
| "light-minimal";
export interface AccentSwatch {
id: string;
name: string;
accent: string;
accentDim: string;
accentBright: string;
}
export interface ThemePreset {
id: PresetId;
name: string;
isDark: boolean;
colors: Record<string, string>;
accentSwatches: AccentSwatch[];
defaultAccentId: string;
}
export type HeadingFont = "serif" | "sans" | "mono";
export interface ThemeConfig {
presetId: PresetId;
accentId: string;
headingFont?: HeadingFont;
}
export const DEFAULT_THEME_CONFIG: ThemeConfig = {
presetId: "dark-gold",
accentId: "gold",
};

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import { deriveContainers } from "../containers";
import type { GraphNode, GraphEdge } from "@understand-anything/core/types";
function node(id: string, filePath?: string): GraphNode {
return {
id,
type: "file",
name: id,
filePath,
summary: "",
complexity: "simple",
tags: [],
} as GraphNode;
}
describe("deriveContainers — folder strategy", () => {
it("groups nodes by first folder segment after LCP", () => {
const nodes = [
node("a", "src/auth/login.go"),
node("b", "src/auth/oauth.go"),
node("c", "src/cart/cart.go"),
node("d", "src/cart/checkout.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
expect(ungrouped).toEqual([]);
expect(containers).toHaveLength(2);
const names = containers.map((c) => c.name).sort();
expect(names).toEqual(["auth", "cart"]);
const auth = containers.find((c) => c.name === "auth")!;
expect(auth.strategy).toBe("folder");
expect(auth.nodeIds.sort()).toEqual(["a", "b"]);
});
it("strips deep LCP", () => {
const nodes = [
node("a", "monorepo/backend/src/auth/login.go"),
node("b", "monorepo/backend/src/cart/cart.go"),
];
const { containers } = deriveContainers(nodes, []);
const names = containers.map((c) => c.name).sort();
expect(names).toEqual(["auth", "cart"]);
});
it("collapses nested folders into the first segment", () => {
const nodes = [
node("a", "auth/handlers/oauth.go"),
node("b", "auth/services/token.go"),
node("c", "cart/cart.go"),
];
const { containers } = deriveContainers(nodes, []);
expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]);
});
it("places nodes without filePath in '~' container", () => {
const nodes = [
node("a", "auth/login.go"),
node("b", "auth/oauth.go"),
node("c"),
node("d"),
];
const { containers } = deriveContainers(nodes, []);
expect(containers.find((c) => c.name === "~")?.nodeIds.sort()).toEqual(["c", "d"]);
});
it("suppresses single-child containers (single child becomes ungrouped)", () => {
const nodes = [
node("a", "auth/login.go"),
node("b", "auth/oauth.go"),
node("c", "cart/cart.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
// 'cart' has only 1 child → suppressed
expect(containers.find((c) => c.name === "cart")).toBeUndefined();
expect(ungrouped).toContain("c");
// 'auth' kept
expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]);
});
it("returns flat (no containers) when total nodes < 8", () => {
const nodes = [
node("a", "auth/x.go"),
node("b", "cart/y.go"),
node("c", "logs/z.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
expect(containers).toHaveLength(0);
expect(ungrouped.sort()).toEqual(["a", "b", "c"]);
});
});
describe("deriveContainers — community fallback", () => {
it("falls back to communities when only one folder present", () => {
const nodes = Array.from({ length: 10 }, (_, i) =>
node(`n${i}`, `services/n${i}.go`),
);
// Two clusters of 5 nodes; densely connected within, no edges between
const edges: GraphEdge[] = [];
for (const i of [0, 1, 2, 3, 4]) {
for (const j of [0, 1, 2, 3, 4]) {
if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
}
}
for (const i of [5, 6, 7, 8, 9]) {
for (const j of [5, 6, 7, 8, 9]) {
if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
}
}
const { containers } = deriveContainers(nodes, edges);
expect(containers.length).toBeGreaterThanOrEqual(2);
for (const c of containers) {
expect(c.strategy).toBe("community");
expect(c.name).toMatch(/^Cluster [A-Z]$/);
}
});
it("falls back when one folder holds > 70%", () => {
const nodes = [
...Array.from({ length: 8 }, (_, i) => node(`big${i}`, `big/file${i}.go`)),
node("a", "small1/a.go"),
node("b", "small2/b.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
// Folder strategy would have produced a 'big' container with 8 children.
// Community fallback (no edges) gives each node its own community → all
// single-child → all suppressed. The non-vacuous evidence the fallback
// path was taken: NO folder-strategy 'big' container survives.
expect(containers.find((c) => c.strategy === "folder" && c.name === "big")).toBeUndefined();
expect(ungrouped.length).toBe(10);
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { aggregateContainerEdges } from "../edgeAggregation";
import type { GraphEdge, EdgeType } from "@understand-anything/core/types";
const ce = (source: string, target: string, type: EdgeType = "calls"): GraphEdge => ({
source,
target,
type,
direction: "forward",
weight: 1,
});
describe("aggregateContainerEdges", () => {
it("returns empty arrays for empty input", () => {
const r = aggregateContainerEdges([], new Map());
expect(r.intraContainer).toEqual([]);
expect(r.interContainerAggregated).toEqual([]);
});
it("preserves intra-container edges as-is", () => {
const m = new Map([
["a", "auth"],
["b", "auth"],
]);
const r = aggregateContainerEdges([ce("a", "b")], m);
expect(r.intraContainer).toHaveLength(1);
expect(r.interContainerAggregated).toEqual([]);
});
it("merges multiple same-direction inter edges into one", () => {
const m = new Map([
["a", "auth"],
["b", "auth"],
["c", "cart"],
["d", "cart"],
]);
const edges = [ce("a", "c"), ce("a", "d"), ce("b", "c", "imports")];
const r = aggregateContainerEdges(edges, m);
expect(r.interContainerAggregated).toHaveLength(1);
const agg = r.interContainerAggregated[0];
expect(agg.sourceContainerId).toBe("auth");
expect(agg.targetContainerId).toBe("cart");
expect(agg.count).toBe(3);
expect(agg.edgeTypes.sort()).toEqual(["calls", "imports"]);
});
it("treats opposite directions as separate aggregated edges", () => {
const m = new Map([
["a", "auth"],
["c", "cart"],
]);
const r = aggregateContainerEdges([ce("a", "c"), ce("c", "a")], m);
expect(r.interContainerAggregated).toHaveLength(2);
const dirs = r.interContainerAggregated.map(
(e) => `${e.sourceContainerId}${e.targetContainerId}`,
);
expect(dirs.sort()).toEqual(["auth→cart", "cart→auth"]);
});
it("ignores edges whose endpoints have no container mapping", () => {
const m = new Map([["a", "auth"]]);
const r = aggregateContainerEdges([ce("a", "z")], m);
expect(r.intraContainer).toEqual([]);
expect(r.interContainerAggregated).toEqual([]);
});
it("does not collide when container ids contain the separator character", () => {
// Pre-fix: key was `${sc} ${tc}` so `("x y", "z")` and `("x", "y z")`
// would both map to `"x y z"`. Length-prefix on source prevents this.
const m = new Map([
["a", "x y"],
["b", "z"],
["c", "x"],
["d", "y z"],
]);
const r = aggregateContainerEdges([ce("a", "b"), ce("c", "d")], m);
expect(r.interContainerAggregated).toHaveLength(2);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import { applyElkLayout, repairElkInput, type ElkInput } from "../elk-layout";
describe("repairElkInput", () => {
it("ensures node dimensions when missing", () => {
const input: ElkInput = {
id: "root",
children: [{ id: "a" }, { id: "b", width: 100, height: 50 }] as ElkInput["children"],
edges: [],
};
const { input: out, issues } = repairElkInput(input);
expect(out.children![0].width).toBeGreaterThan(0);
expect(out.children![0].height).toBeGreaterThan(0);
expect(out.children![1]).toEqual({ id: "b", width: 100, height: 50 });
expect(issues.some((i) => i.level === "auto-corrected" && /dimensions/.test(i.message))).toBe(true);
});
it("dedupes duplicate child ids and reports auto-corrected", () => {
const input: ElkInput = {
id: "root",
children: [
{ id: "a", width: 1, height: 1 },
{ id: "a", width: 1, height: 1 },
],
edges: [],
};
const { input: out, issues } = repairElkInput(input);
expect(out.children).toHaveLength(1);
expect(issues.some((i) => i.level === "auto-corrected" && /duplicate/.test(i.message))).toBe(true);
});
it("drops orphan edges referencing nonexistent nodes", () => {
const input: ElkInput = {
id: "root",
children: [{ id: "a", width: 1, height: 1 }],
edges: [
{ id: "e1", sources: ["a"], targets: ["ghost"] },
],
};
const { input: out, issues } = repairElkInput(input);
expect(out.edges).toHaveLength(0);
expect(issues.some((i) => i.level === "dropped" && /edge/.test(i.message))).toBe(true);
});
it("drops children referencing nonexistent parents", () => {
const input: ElkInput = {
id: "root",
children: [
{
id: "p",
width: 100,
height: 100,
children: [{ id: "c1", width: 1, height: 1 }],
},
{ id: "orphan", width: 1, height: 1, parentId: "ghost" } as ElkInput["children"][0] & { parentId: string },
],
edges: [],
};
const { input: out, issues } = repairElkInput(input);
expect(out.children!.find((c) => c.id === "orphan")).toBeUndefined();
expect(issues.some((i) => i.level === "dropped" && /parent/.test(i.message))).toBe(true);
});
it("strict mode throws on any issue", () => {
const input: ElkInput = {
id: "root",
children: [{ id: "a" }] as ElkInput["children"],
edges: [],
};
expect(() => repairElkInput(input, { strict: true })).toThrow(/dimensions/);
});
});
describe("applyElkLayout", () => {
it("lays out a small graph and returns positions", async () => {
const result = await applyElkLayout({
id: "root",
children: [
{ id: "a", width: 100, height: 50 },
{ id: "b", width: 100, height: 50 },
],
edges: [{ id: "e1", sources: ["a"], targets: ["b"] }],
layoutOptions: { algorithm: "layered", "elk.direction": "DOWN" },
});
expect(result.issues).toEqual([]);
expect(result.positioned.children).toHaveLength(2);
for (const c of result.positioned.children) {
expect(typeof c.x).toBe("number");
expect(typeof c.y).toBe("number");
}
});
it("returns fatal issue when ELK rejects (without throwing in non-strict)", async () => {
// Force ELK rejection by giving an invalid algorithm
const result = await applyElkLayout(
{
id: "root",
children: [{ id: "a", width: 1, height: 1 }],
edges: [],
layoutOptions: { algorithm: "this-algorithm-does-not-exist" },
},
{ strict: false },
);
expect(result.issues.some((i) => i.level === "fatal")).toBe(true);
});
});

View File

@@ -0,0 +1,196 @@
import { describe, it, expect } from "vitest";
import { filterNodes, filterEdges } from "../filters";
import type {
GraphNode,
GraphEdge,
Layer,
} from "@understand-anything/core/types";
import type {
FilterState,
NodeType,
Complexity,
EdgeCategory,
} from "../../store";
import {
ALL_NODE_TYPES,
ALL_COMPLEXITIES,
ALL_EDGE_CATEGORIES,
} from "../../store";
function node(
id: string,
type: NodeType = "file",
complexity: Complexity = "simple",
): GraphNode {
return {
id,
type,
name: id,
summary: "",
complexity,
tags: [],
} as GraphNode;
}
function edge(source: string, target: string, type = "imports"): GraphEdge {
return { source, target, type } as GraphEdge;
}
function defaultFilters(overrides: Partial<FilterState> = {}): FilterState {
return {
nodeTypes: new Set<NodeType>(ALL_NODE_TYPES),
complexities: new Set<Complexity>(ALL_COMPLEXITIES),
layerIds: new Set<string>(),
edgeCategories: new Set<EdgeCategory>(ALL_EDGE_CATEGORIES),
...overrides,
};
}
function indexLayers(layers: Layer[]): Map<string, Set<string>> {
const m = new Map<string, Set<string>>();
for (const l of layers) {
for (const nid of l.nodeIds) {
let set = m.get(nid);
if (!set) {
set = new Set<string>();
m.set(nid, set);
}
set.add(l.id);
}
}
return m;
}
describe("filterNodes", () => {
it("returns all nodes when no filters narrow the set", () => {
const nodes = [node("a"), node("b"), node("c")];
const out = filterNodes(nodes, new Map(), defaultFilters());
expect(out).toHaveLength(3);
});
it("filters by node type", () => {
const nodes = [node("a", "file"), node("b", "function"), node("c", "class")];
const filters = defaultFilters({ nodeTypes: new Set(["file"]) });
const out = filterNodes(nodes, new Map(), filters);
expect(out.map((n) => n.id)).toEqual(["a"]);
});
it("filters by complexity", () => {
const nodes = [
node("a", "file", "simple"),
node("b", "file", "moderate"),
node("c", "file", "complex"),
];
const filters = defaultFilters({ complexities: new Set(["complex"]) });
const out = filterNodes(nodes, new Map(), filters);
expect(out.map((n) => n.id)).toEqual(["c"]);
});
it("keeps a node only when its layer is selected", () => {
const nodes = [node("a"), node("b"), node("c")];
const idx = indexLayers([
{ id: "L1", name: "L1", description: "", nodeIds: ["a", "b"] },
{ id: "L2", name: "L2", description: "", nodeIds: ["c"] },
]);
const filters = defaultFilters({ layerIds: new Set(["L1"]) });
const out = filterNodes(nodes, idx, filters);
expect(out.map((n) => n.id).sort()).toEqual(["a", "b"]);
});
it("drops nodes that aren't in any layer when a layer filter is active", () => {
const nodes = [node("a"), node("orphan")];
const idx = indexLayers([
{ id: "L1", name: "L1", description: "", nodeIds: ["a"] },
]);
const filters = defaultFilters({ layerIds: new Set(["L1"]) });
const out = filterNodes(nodes, idx, filters);
expect(out.map((n) => n.id)).toEqual(["a"]);
});
it("keeps a multi-layer node when any of its layers is selected (any-layer-wins)", () => {
// Regression for the silent first-wins behavior change in #112: a node
// X listed in both L1 (declared first) and L2, with only L2 selected,
// must still pass — matching the prior `layers.some(...)` shape. The
// first-wins `nodeIdToLayerId` index that drives navigation would
// have dropped X here.
const nodes = [node("x"), node("y")];
const idx = indexLayers([
{ id: "L1", name: "L1", description: "", nodeIds: ["x"] },
{ id: "L2", name: "L2", description: "", nodeIds: ["x", "y"] },
]);
const filters = defaultFilters({ layerIds: new Set(["L2"]) });
const out = filterNodes(nodes, idx, filters);
expect(out.map((n) => n.id).sort()).toEqual(["x", "y"]);
});
it("ignores layer filter when no layers are selected (parity with prior shape)", () => {
const nodes = [node("a"), node("orphan")];
// idx maps "a"; "orphan" isn't in any layer. With layer filter empty,
// the orphan must still pass through.
const idx = indexLayers([
{ id: "L1", name: "L1", description: "", nodeIds: ["a"] },
]);
const out = filterNodes(nodes, idx, defaultFilters());
expect(out.map((n) => n.id).sort()).toEqual(["a", "orphan"]);
});
it("scales linearly: 10k nodes × 100 layers under 50ms (#102 regression guard)", () => {
// The pre-fix path was O(N × L × K) — `layers.some(layer => filters.layerIds.has(layer.id) && layer.nodeIds.includes(node.id))`.
// For a 10k-node / 100-layer graph with half the layers selected, that
// measured ~50ms locally on node 22 just for the filter step.
const nodes: GraphNode[] = [];
const layers: Layer[] = [];
for (let li = 0; li < 100; li++) {
const nodeIds: string[] = [];
for (let ni = 0; ni < 100; ni++) {
const id = `n-${li}-${ni}`;
nodes.push(node(id));
nodeIds.push(id);
}
layers.push({ id: `L${li}`, name: `L${li}`, description: "", nodeIds });
}
const idx = indexLayers(layers);
const selected = new Set<string>(layers.slice(0, 50).map((l) => l.id));
const filters = defaultFilters({ layerIds: selected });
const t0 = performance.now();
const out = filterNodes(nodes, idx, filters);
const elapsedMs = performance.now() - t0;
expect(out.length).toBe(50 * 100);
expect(elapsedMs).toBeLessThan(50);
});
});
describe("filterEdges", () => {
it("keeps only edges whose endpoints are visible", () => {
const edges = [edge("a", "b"), edge("a", "missing"), edge("c", "b")];
const visible = new Set(["a", "b"]);
const out = filterEdges(edges, visible, defaultFilters());
expect(out).toEqual([edge("a", "b")]);
});
it("filters by edge category", () => {
const edges = [
edge("a", "b", "imports"), // structural
edge("a", "b", "calls"), // behavioral
edge("a", "b", "reads_from"), // data-flow
];
const visible = new Set(["a", "b"]);
const filters = defaultFilters({
edgeCategories: new Set<EdgeCategory>(["structural"]),
});
const out = filterEdges(edges, visible, filters);
expect(out.map((e) => e.type)).toEqual(["imports"]);
});
it("passes through edges with unknown types (no category match)", () => {
// getEdgeCategory returns null for unknown types, which short-circuits
// the category filter — pinning current behavior so a future refactor
// doesn't accidentally start dropping unknown edges.
const edges = [edge("a", "b", "future-edge-type")];
const visible = new Set(["a", "b"]);
const out = filterEdges(edges, visible, defaultFilters());
expect(out).toHaveLength(1);
});
});

View File

@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { computeLayerStats } from "../layerStats";
import type { GraphNode, Layer } from "@understand-anything/core/types";
function node(
id: string,
complexity: GraphNode["complexity"] = "simple",
): GraphNode {
return {
id,
type: "file",
name: id,
summary: "",
complexity,
tags: [],
} as GraphNode;
}
function layer(id: string, nodeIds: string[]): Layer {
return {
id,
name: id,
description: "",
nodeIds,
};
}
function indexById(nodes: GraphNode[]): Map<string, GraphNode> {
return new Map(nodes.map((n) => [n.id, n]));
}
describe("computeLayerStats", () => {
it("counts only nodes that resolve in nodesById", () => {
const nodes = [node("a", "simple"), node("b", "moderate"), node("c", "complex")];
const l = layer("L", ["a", "b", "c", "ghost"]);
const stats = computeLayerStats(l, indexById(nodes));
expect(stats.resolvedCount).toBe(3);
});
it("returns 'simple' when no complexity passes the 30% threshold", () => {
// 1 complex out of 4 = 25% — under threshold.
const nodes = [
node("a", "simple"),
node("b", "simple"),
node("c", "simple"),
node("d", "complex"),
];
const stats = computeLayerStats(layer("L", ["a", "b", "c", "d"]), indexById(nodes));
expect(stats.aggregateComplexity).toBe("simple");
});
it("returns 'complex' when complex count strictly exceeds 30%", () => {
// 4 complex out of 10 = 40% — over threshold.
const nodes = Array.from({ length: 10 }, (_, i) =>
node(`n${i}`, i < 4 ? "complex" : "simple"),
);
const stats = computeLayerStats(
layer("L", nodes.map((n) => n.id)),
indexById(nodes),
);
expect(stats.aggregateComplexity).toBe("complex");
});
it("prefers 'complex' over 'moderate' when both clear the threshold", () => {
// 4 complex + 4 moderate out of 10 — complex wins via the order of checks
// in the prior implementation; this test pins that behavior.
const nodes = Array.from({ length: 10 }, (_, i) =>
node(`n${i}`, i < 4 ? "complex" : i < 8 ? "moderate" : "simple"),
);
const stats = computeLayerStats(
layer("L", nodes.map((n) => n.id)),
indexById(nodes),
);
expect(stats.aggregateComplexity).toBe("complex");
});
it("returns 'moderate' when only the moderate count clears the threshold", () => {
const nodes = Array.from({ length: 10 }, (_, i) =>
node(`n${i}`, i < 4 ? "moderate" : "simple"),
);
const stats = computeLayerStats(
layer("L", nodes.map((n) => n.id)),
indexById(nodes),
);
expect(stats.aggregateComplexity).toBe("moderate");
});
it("treats an empty layer as 'simple' with resolvedCount 0", () => {
const stats = computeLayerStats(layer("L", []), indexById([]));
expect(stats.resolvedCount).toBe(0);
expect(stats.aggregateComplexity).toBe("simple");
});
it("aggregates a 100-layer / 100-nodes-per-layer graph in under 50ms (#102 regression guard)", () => {
// The pre-fix path ran graph.nodes.filter((n) => layer.nodeIds.includes(n.id))
// per layer — O(N × K × L) — and locally took ~150ms for this shape under
// node 22. The new path is O(N + Σ K_i). Loose budget so CI variance
// doesn't flake; the pre-fix path would blow past it by 2-10×.
const nodes: GraphNode[] = [];
const layers: Layer[] = [];
for (let li = 0; li < 100; li++) {
const ids: string[] = [];
for (let ni = 0; ni < 100; ni++) {
const id = `n-${li}-${ni}`;
nodes.push(node(id, ((li + ni) % 3 === 0 ? "complex" : (li + ni) % 3 === 1 ? "moderate" : "simple")));
ids.push(id);
}
layers.push(layer(`L${li}`, ids));
}
const byId = indexById(nodes);
const t0 = performance.now();
for (const l of layers) computeLayerStats(l, byId);
const elapsedMs = performance.now() - t0;
expect(elapsedMs).toBeLessThan(50);
});
});

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import ELK from "elkjs/lib/elk.bundled.js";
import Graph from "graphology";
import louvain from "graphology-communities-louvain";
describe("dependency smoke test", () => {
it("imports elkjs", () => {
expect(typeof ELK).toBe("function");
});
it("imports graphology", () => {
const g = new Graph();
g.addNode("a");
expect(g.order).toBe(1);
});
it("imports graphology-communities-louvain", () => {
expect(typeof louvain).toBe("function");
});
});

View File

@@ -0,0 +1,156 @@
import type {
GraphNode,
GraphEdge,
} from "@understand-anything/core/types";
import { detectCommunities } from "./louvain";
export interface DerivedContainer {
id: string;
name: string;
nodeIds: string[];
strategy: "folder" | "community";
}
export interface DeriveResult {
containers: DerivedContainer[];
ungrouped: string[];
}
const MIN_BUCKET_COUNT = 2;
const MAX_CONCENTRATION = 0.7;
const MIN_NODES_FOR_SUPPRESSION = 3;
const ROOT_BUCKET = "~";
/**
* Longest common prefix of the *directory* portion of paths, trimmed to a
* `/` boundary. Using dirs (not full paths) avoids consuming the only
* folder segment when all paths sit directly under the same folder
* (e.g. `[auth/x, auth/y]` → LCP `""`, so we still group on `auth`).
*/
function commonPrefix(paths: string[]): string {
if (paths.length === 0) return "";
const dirs = paths.map((p) => {
const slash = p.lastIndexOf("/");
return slash >= 0 ? p.slice(0, slash) : "";
});
let prefix = dirs[0];
for (const d of dirs) {
while (!d.startsWith(prefix)) {
prefix = prefix.slice(0, -1);
if (!prefix) return "";
}
}
const lastSlash = prefix.lastIndexOf("/");
return lastSlash >= 0 ? prefix.slice(0, lastSlash + 1) : "";
}
function firstSegment(path: string): string {
const slash = path.indexOf("/");
return slash >= 0 ? path.slice(0, slash) : path;
}
function groupByFolder(
nodes: GraphNode[],
): { groups: Map<string, string[]>; rooted: string[] } {
const withPath = nodes.filter((n) => n.filePath);
const lcp = commonPrefix(withPath.map((n) => n.filePath!));
const groups = new Map<string, string[]>();
const rooted: string[] = [];
for (const n of nodes) {
if (!n.filePath) {
rooted.push(n.id);
continue;
}
const stripped = n.filePath.slice(lcp.length);
if (!stripped.includes("/")) {
rooted.push(n.id);
continue;
}
const seg = firstSegment(stripped);
const arr = groups.get(seg) ?? [];
arr.push(n.id);
groups.set(seg, arr);
}
return { groups, rooted };
}
function shouldFallbackToCommunity(
groups: Map<string, string[]>,
rooted: string[],
totalNodes: number,
): boolean {
const bucketCount = groups.size + (rooted.length > 0 ? 1 : 0);
if (bucketCount < MIN_BUCKET_COUNT) return true;
for (const ids of groups.values()) {
if (ids.length / totalNodes > MAX_CONCENTRATION) return true;
}
if (rooted.length / totalNodes > MAX_CONCENTRATION) return true;
return false;
}
export function deriveContainers(
nodes: GraphNode[],
edges: GraphEdge[],
): DeriveResult {
if (nodes.length === 0) {
return { containers: [], ungrouped: [] };
}
const { groups, rooted } = groupByFolder(nodes);
const useCommunity = shouldFallbackToCommunity(groups, rooted, nodes.length);
let containers: DerivedContainer[];
if (useCommunity) {
const communities = detectCommunities(
nodes.map((n) => n.id),
edges,
);
const byCommunity = new Map<number, string[]>();
for (const [nodeId, cid] of communities) {
const arr = byCommunity.get(cid) ?? [];
arr.push(nodeId);
byCommunity.set(cid, arr);
}
const sorted = [...byCommunity.entries()].sort((a, b) => a[0] - b[0]);
containers = sorted.map(([cid, ids], i) => ({
id: `container:cluster-${cid}`,
// A-Z for the first 26, then numeric. Avoids `String.fromCharCode(65+i)`
// wrapping into `[`, `\`, `]` ... once the cluster count exceeds 26.
name: i < 26 ? `Cluster ${String.fromCharCode(65 + i)}` : `Cluster ${i + 1}`,
nodeIds: ids,
strategy: "community" as const,
}));
} else {
containers = [...groups.entries()].map(([seg, ids]) => ({
id: `container:${seg}`,
name: seg,
nodeIds: ids,
strategy: "folder" as const,
}));
if (rooted.length > 0) {
containers.push({
id: `container:${ROOT_BUCKET}`,
name: ROOT_BUCKET,
nodeIds: rooted,
strategy: "folder" as const,
});
}
}
// Suppress single-child containers (their child becomes ungrouped).
// Skip suppression for tiny layers — with so few nodes, even single-item
// boxes carry useful folder context that shouldn't be discarded.
const ungrouped: string[] = [];
if (nodes.length >= MIN_NODES_FOR_SUPPRESSION) {
containers = containers.filter((c) => {
if (c.nodeIds.length === 1) {
ungrouped.push(c.nodeIds[0]);
return false;
}
return true;
});
}
return { containers, ungrouped };
}

View File

@@ -0,0 +1,244 @@
import ELK from "elkjs/lib/elk.bundled.js";
import type { GraphIssue } from "@understand-anything/core/schema";
import { NODE_WIDTH, NODE_HEIGHT } from "./layout";
export interface ElkChild {
id: string;
width?: number;
height?: number;
/** Set by ELK after layout; absent on input. Downstream consumers must default. */
x?: number;
y?: number;
children?: ElkChild[];
parentId?: string;
}
export interface ElkEdge {
id: string;
sources: string[];
targets: string[];
}
export interface ElkInput {
id: string;
children: ElkChild[];
edges: ElkEdge[];
layoutOptions?: Record<string, string>;
}
// Keep ELK fallback dimensions in lockstep with the dagre/force NODE
// dimensions in utils/layout.ts so layouts stay collision-consistent
// during the migration.
const DEFAULT_NODE_WIDTH = NODE_WIDTH;
const DEFAULT_NODE_HEIGHT = NODE_HEIGHT;
interface RepairOptions {
strict?: boolean;
}
interface RepairResult {
input: ElkInput;
issues: GraphIssue[];
}
function makeIssue(
level: GraphIssue["level"],
category: string,
message: string,
): GraphIssue {
return { level, category, message };
}
function maybeThrow(strict: boolean | undefined, issue: GraphIssue): void {
if (strict) throw new Error(`[ELK repair] ${issue.level}: ${issue.message}`);
}
export function repairElkInput(
input: ElkInput,
opts: RepairOptions = {},
): RepairResult {
const issues: GraphIssue[] = [];
const strict = opts.strict;
// 1. ensureNodeDimensions
let dimsAdded = 0;
const fillDims = (children: ElkChild[]): ElkChild[] =>
children.map((c) => {
const next: ElkChild = { ...c };
if (next.width == null || next.height == null) {
next.width = next.width ?? DEFAULT_NODE_WIDTH;
next.height = next.height ?? DEFAULT_NODE_HEIGHT;
dimsAdded++;
}
if (next.children) next.children = fillDims(next.children);
return next;
});
const childrenA = fillDims(input.children);
if (dimsAdded > 0) {
const issue = makeIssue(
"auto-corrected",
"elk-missing-dimensions",
`Set default dimensions on ${dimsAdded} node(s) missing width/height.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 2. dedupeNodeIds (per parent)
let dupesRemoved = 0;
const dedupe = (children: ElkChild[]): ElkChild[] => {
const seen = new Set<string>();
const out: ElkChild[] = [];
for (const c of children) {
if (seen.has(c.id)) {
dupesRemoved++;
continue;
}
seen.add(c.id);
out.push({
...c,
children: c.children ? dedupe(c.children) : undefined,
});
}
return out;
};
const childrenB = dedupe(childrenA);
if (dupesRemoved > 0) {
const issue = makeIssue(
"auto-corrected",
"elk-duplicate-id",
`Removed ${dupesRemoved} duplicate child id(s).`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 3. dropOrphanChildren — children whose parentId references nonexistent parent
const allIds = new Set<string>();
const walk = (children: ElkChild[]) => {
for (const c of children) {
allIds.add(c.id);
if (c.children) walk(c.children);
}
};
walk(childrenB);
let orphanChildren = 0;
const childrenC = childrenB.filter((c) => {
if (c.parentId && !allIds.has(c.parentId)) {
orphanChildren++;
return false;
}
return true;
});
if (orphanChildren > 0) {
const issue = makeIssue(
"dropped",
"elk-orphan-parent",
`Dropped ${orphanChildren} child(ren) with missing parent reference.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 4. dropOrphanEdges
let orphanEdges = 0;
const edges = input.edges.filter((e) => {
const ok = e.sources.every((s) => allIds.has(s)) &&
e.targets.every((t) => allIds.has(t));
if (!ok) {
orphanEdges++;
return false;
}
return true;
});
if (orphanEdges > 0) {
const issue = makeIssue(
"dropped",
"elk-orphan-edge",
`Dropped ${orphanEdges} edge(s) referencing nonexistent nodes.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 5. dropCircularContainment
const parentOf = new Map<string, string>();
const fillParents = (children: ElkChild[], parent?: string) => {
for (const c of children) {
if (parent) parentOf.set(c.id, parent);
if (c.children) fillParents(c.children, c.id);
}
};
fillParents(childrenC);
let cyclesRemoved = 0;
const isCyclic = (id: string): boolean => {
const seen = new Set<string>();
let cur = parentOf.get(id);
while (cur) {
if (cur === id || seen.has(cur)) return true;
seen.add(cur);
cur = parentOf.get(cur);
}
return false;
};
const stripCycles = (children: ElkChild[]): ElkChild[] =>
children
.filter((c) => {
if (isCyclic(c.id)) {
cyclesRemoved++;
return false;
}
return true;
})
.map((c) => ({
...c,
children: c.children ? stripCycles(c.children) : undefined,
}));
const childrenD = stripCycles(childrenC);
if (cyclesRemoved > 0) {
const issue = makeIssue(
"dropped",
"elk-containment-cycle",
`Dropped ${cyclesRemoved} node(s) in containment cycles.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
return {
input: { ...input, children: childrenD, edges },
issues,
};
}
const elk = new ELK();
export interface ElkLayoutOptions {
strict?: boolean;
}
export interface ElkLayoutResult {
positioned: ElkInput;
issues: GraphIssue[];
}
export async function applyElkLayout(
input: ElkInput,
opts: ElkLayoutOptions = {},
): Promise<ElkLayoutResult> {
const { input: repaired, issues } = repairElkInput(input, opts);
try {
const positioned = (await elk.layout(repaired as never)) as ElkInput;
return { positioned, issues };
} catch (err) {
const fatal: GraphIssue = {
level: "fatal",
category: "elk-layout-failed",
message:
`ELK layout failed: ${err instanceof Error ? err.message : String(err)}. ` +
`This looks like a dashboard rendering bug — please file an issue with the copied error.`,
};
if (opts.strict) throw err;
return { positioned: { ...repaired, children: [], edges: [] }, issues: [...issues, fatal] };
}
}

View File

@@ -0,0 +1,88 @@
import type { GraphNode, GraphEdge } from "@understand-anything/core/types";
import type { FilterState, NodeType, Complexity, EdgeCategory } from "../store";
import { EDGE_CATEGORY_MAP } from "../store";
/**
* Filter nodes based on active filters.
*
* Pass `nodeIdToLayerIds` from the store (precomputed once on `setGraph`)
* so the layer-membership check is O(1) per node. The previous shape took
* `Layer[]` and ran `layer.nodeIds.includes(node.id)` per node-per-layer,
* which was O(N × L × K) and dominated export time on large graphs (#102).
*
* Membership semantics are any-layer-wins, matching the prior shape: a
* node in L1 and L2 with only L2 selected passes. The store's other
* index, `nodeIdToLayerId`, is first-wins and is for navigation, not
* filtering — using it here would silently drop multi-layer nodes whose
* first declared layer isn't selected.
*/
export function filterNodes(
nodes: GraphNode[],
nodeIdToLayerIds: Map<string, Set<string>>,
filters: FilterState,
): GraphNode[] {
const hasLayerFilter = filters.layerIds.size > 0;
return nodes.filter((node) => {
// Filter by node type
if (!filters.nodeTypes.has(node.type as NodeType)) {
return false;
}
// Filter by complexity
if (node.complexity && !filters.complexities.has(node.complexity as Complexity)) {
return false;
}
// Filter by layer (if any layers are selected)
if (hasLayerFilter) {
const layerIds = nodeIdToLayerIds.get(node.id);
if (!layerIds) return false;
let inSelected = false;
for (const lid of layerIds) {
if (filters.layerIds.has(lid)) {
inSelected = true;
break;
}
}
if (!inSelected) return false;
}
return true;
});
}
/**
* Filter edges based on visible nodes and active edge category filters
*/
export function filterEdges(
edges: GraphEdge[],
visibleNodeIds: Set<string>,
filters: FilterState,
): GraphEdge[] {
return edges.filter((edge) => {
// Only keep edges between visible nodes
if (!visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)) {
return false;
}
// Filter by edge category
const edgeCategory = getEdgeCategory(edge.type);
if (edgeCategory && !filters.edgeCategories.has(edgeCategory)) {
return false;
}
return true;
});
}
/**
* Determine which category an edge type belongs to
*/
function getEdgeCategory(edgeType: string): EdgeCategory | null {
for (const [category, types] of Object.entries(EDGE_CATEGORY_MAP)) {
if (types.includes(edgeType)) {
return category as EdgeCategory;
}
}
return null;
}

View File

@@ -0,0 +1,39 @@
import type { GraphNode, Layer } from "@understand-anything/core/types";
export type Complexity = "simple" | "moderate" | "complex";
export interface LayerStats {
/** Number of layer.nodeIds that resolve to a node in the graph. */
resolvedCount: number;
/** Aggregate label for the cluster card; matches the prior 30% threshold. */
aggregateComplexity: Complexity;
}
/**
* O(layer.nodeIds.length) summary of a layer's complexity composition.
*
* Replaces the prior `graph.nodes.filter((n) => layer.nodeIds.includes(n.id))`
* pass in `useOverviewGraph`, which was O(N × K) per layer and went
* super-linear once a project had a few thousand nodes spread across many
* layers (#102: 4.8 MB graph froze on overview render).
*/
export function computeLayerStats(
layer: Layer,
nodesById: Map<string, GraphNode>,
): LayerStats {
const counts: Record<Complexity, number> = { simple: 0, moderate: 0, complex: 0 };
let resolved = 0;
for (const nid of layer.nodeIds) {
const node = nodesById.get(nid);
if (!node) continue;
resolved++;
counts[node.complexity]++;
}
const aggregateComplexity: Complexity =
counts.complex > resolved * 0.3
? "complex"
: counts.moderate > resolved * 0.3
? "moderate"
: "simple";
return { resolvedCount: resolved, aggregateComplexity };
}

View File

@@ -0,0 +1,266 @@
import dagre from "@dagrejs/dagre";
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceX,
forceY,
} from "d3-force";
import type { SimulationNodeDatum, SimulationLinkDatum } from "d3-force";
import type { Node, Edge } from "@xyflow/react";
import type { ElkInput } from "./elk-layout";
export const NODE_WIDTH = 280;
export const NODE_HEIGHT = 120;
export const LAYER_CLUSTER_WIDTH = 320;
export const LAYER_CLUSTER_HEIGHT = 180;
export const PORTAL_NODE_WIDTH = 240;
export const PORTAL_NODE_HEIGHT = 80;
/**
* Synchronous dagre layout — used for small graphs.
*
* @deprecated The dashboard's structural views all use ELK now
* (`applyElkLayout` from `./elk-layout`). This helper is kept for one
* release to allow a quick fallback if ELK has a regression. Slated for
* removal in the version after layout migration is verified stable.
*/
export function applyDagreLayout(
nodes: Node[],
edges: Edge[],
direction: "TB" | "LR" = "TB",
nodeDimensions?: Map<string, { width: number; height: number }>,
spacingOverrides?: { nodesep?: number; ranksep?: number },
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
// Scale spacing for larger graphs to reduce overlap
const isLarge = nodes.length > 50;
g.setGraph({
rankdir: direction,
nodesep: spacingOverrides?.nodesep ?? (isLarge ? 80 : 60),
ranksep: spacingOverrides?.ranksep ?? (isLarge ? 120 : 80),
marginx: 20,
marginy: 20,
});
nodes.forEach((node) => {
const dims = nodeDimensions?.get(node.id);
const w = dims?.width ?? NODE_WIDTH;
const h = dims?.height ?? NODE_HEIGHT;
g.setNode(node.id, { width: w, height: h });
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
const layoutedNodes = nodes.map((node) => {
const pos = g.node(node.id);
if (!pos) return { ...node, position: { x: 0, y: 0 } };
const dims = nodeDimensions?.get(node.id);
const w = dims?.width ?? NODE_WIDTH;
const h = dims?.height ?? NODE_HEIGHT;
return {
...node,
position: {
x: pos.x - w / 2,
y: pos.y - h / 2,
},
};
});
return { nodes: layoutedNodes, edges };
}
// ---------------------------------------------------------------------------
// Force-directed layout (for knowledge graphs)
// ---------------------------------------------------------------------------
interface ForceNode extends SimulationNodeDatum {
id: string;
community?: number;
}
/**
* Force-directed layout using d3-force — used for knowledge graphs.
* Optionally groups nodes by community (layer/category).
*/
export function applyForceLayout(
nodes: Node[],
edges: Edge[],
nodeDimensions?: Map<string, { width: number; height: number }>,
communityMap?: Map<string, number>,
): { nodes: Node[]; edges: Edge[] } {
if (nodes.length === 0) return { nodes, edges };
// Build simulation nodes with optional community assignment
const simNodes: ForceNode[] = nodes.map((n) => ({
id: n.id,
x: Math.random() * 800 - 400,
y: Math.random() * 800 - 400,
community: communityMap?.get(n.id),
}));
const nodeIdSet = new Set(simNodes.map((n) => n.id));
const simLinks: SimulationLinkDatum<ForceNode>[] = edges
.filter((e) => nodeIdSet.has(e.source as string) && nodeIdSet.has(e.target as string))
.map((e) => ({
source: e.source as string,
target: e.target as string,
}));
// Compute community centers for cluster attraction
const communityCount = communityMap
? Math.max(1, new Set(communityMap.values()).size)
: 1;
const communityAngle = (i: number) => (2 * Math.PI * i) / communityCount;
// Scale cluster radius with node count for better spread
const clusterRadius = Math.max(600, nodes.length * 5);
// Scale forces based on graph size
const isLarge = nodes.length > 100;
const chargeStrength = isLarge ? -600 : -350;
const linkDistance = isLarge ? 250 : 150;
const sim = forceSimulation<ForceNode>(simNodes)
.force(
"link",
forceLink<ForceNode, SimulationLinkDatum<ForceNode>>(simLinks)
.id((d) => d.id)
.distance(linkDistance)
.strength(0.2),
)
.force("charge", forceManyBody().strength(chargeStrength).distanceMax(1500))
.force("center", forceCenter(0, 0).strength(0.03))
.force(
"collide",
forceCollide<ForceNode>().radius((d) => {
const dims = nodeDimensions?.get(d.id);
return Math.max(20, ((dims?.width ?? NODE_WIDTH) + 40) / 2);
}).strength(0.8),
);
// Add community clustering force if communities are provided
if (communityMap && communityCount > 1) {
sim.force(
"clusterX",
forceX<ForceNode>((d) => {
const c = d.community ?? 0;
return Math.cos(communityAngle(c)) * clusterRadius;
}).strength(0.3),
);
sim.force(
"clusterY",
forceY<ForceNode>((d) => {
const c = d.community ?? 0;
return Math.sin(communityAngle(c)) * clusterRadius;
}).strength(0.3),
);
}
// Run to convergence synchronously
const ticks = Math.min(300, Math.max(100, nodes.length));
sim.tick(ticks);
sim.stop();
// Map positions back to xyflow nodes
const posMap = new Map(simNodes.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }]));
const layoutedNodes = nodes.map((node) => {
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
const dims = nodeDimensions?.get(node.id);
const w = dims?.width ?? NODE_WIDTH;
const h = dims?.height ?? NODE_HEIGHT;
return {
...node,
position: {
x: pos.x - w / 2,
y: pos.y - h / 2,
},
};
});
return { nodes: layoutedNodes, edges };
}
// ---------------------------------------------------------------------------
// ELK helpers
// ---------------------------------------------------------------------------
export const ELK_DEFAULT_LAYOUT_OPTIONS: Record<string, string> = {
algorithm: "layered",
"elk.direction": "DOWN",
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
"elk.spacing.nodeNode": "60",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.compaction.postCompaction.strategy": "LEFT",
"elk.padding": "[top=40,left=20,right=20,bottom=20]",
};
export function nodesToElkInput(
nodes: Node[],
edges: Edge[],
dims: Map<string, { width: number; height: number }>,
layoutOptionsOverride?: Record<string, string>,
): ElkInput {
return {
id: "root",
layoutOptions: { ...ELK_DEFAULT_LAYOUT_OPTIONS, ...layoutOptionsOverride },
children: nodes.map((n) => {
const d = dims.get(n.id);
return {
id: n.id,
width: d?.width ?? NODE_WIDTH,
height: d?.height ?? NODE_HEIGHT,
};
}),
edges: edges.map((e, i) => ({
id: e.id ?? `e${i}`,
sources: [String(e.source)],
targets: [String(e.target)],
})),
};
}
export function mergeElkPositions<T extends Node>(
nodes: T[],
positioned: ElkInput,
): T[] {
const positionedMap = new Map<
string,
{ x: number; y: number; width?: number; height?: number }
>();
for (const c of positioned.children ?? []) {
positionedMap.set(c.id, {
x: c.x ?? 0,
y: c.y ?? 0,
width: c.width,
height: c.height,
});
}
return nodes.map((n) => {
const merged = positionedMap.get(n.id);
if (!merged) {
return {
...n,
position: n.position ?? { x: 0, y: 0 },
};
}
// Propagate width/height for container nodes so a tick-driven
// Stage 1 re-layout (Task 15) can resize the visible atom to match
// the actual Stage 2 footprint. ELK echoes back the same width/height
// we passed in for non-container nodes, so this is a no-op for them.
return {
...n,
position: { x: merged.x, y: merged.y },
...(merged.width != null ? { width: merged.width } : {}),
...(merged.height != null ? { height: merged.height } : {}),
};
});
}

View File

@@ -0,0 +1,47 @@
import dagre from "@dagrejs/dagre";
export interface LayoutMessage {
requestId: number;
nodes: Array<{ id: string; width: number; height: number }>;
edges: Array<{ source: string; target: string }>;
direction: "TB" | "LR";
}
export interface LayoutResult {
requestId: number;
positions: Record<string, { x: number; y: number }>;
}
self.onmessage = (e: MessageEvent<LayoutMessage>) => {
const { requestId, nodes, edges, direction } = e.data;
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: direction,
nodesep: 60,
ranksep: 80,
marginx: 20,
marginy: 20,
});
for (const node of nodes) {
g.setNode(node.id, { width: node.width, height: node.height });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
dagre.layout(g);
const positions: Record<string, { x: number; y: number }> = {};
for (const node of nodes) {
const pos = g.node(node.id);
positions[node.id] = pos
? { x: pos.x - node.width / 2, y: pos.y - node.height / 2 }
: { x: 0, y: 0 };
}
self.postMessage({ requestId, positions } satisfies LayoutResult);
};

View File

@@ -0,0 +1,47 @@
import Graph from "graphology";
import louvain from "graphology-communities-louvain";
import type { GraphEdge } from "@understand-anything/core/types";
/**
* Run Louvain community detection over the provided node set and the
* subset of edges whose endpoints are both in the set. Returns a map of
* nodeId → communityId.
*
* graphology-communities-louvain v2 already gives each disconnected node
* its own community id, but the contract isn't documented. The
* post-Louvain reassignment loop below is defensive: if a future version
* starts returning -1 (or omits a node, which the `?? -1` catches) for
* unmatched nodes, we'll still hand back unique ids rather than letting
* them collapse into a single cluster.
*/
export function detectCommunities(
nodeIds: string[],
edges: GraphEdge[],
): Map<string, number> {
const ids = new Set(nodeIds);
const g = new Graph({ type: "undirected", multi: false });
for (const id of nodeIds) g.addNode(id);
for (const e of edges) {
if (!ids.has(e.source) || !ids.has(e.target)) continue;
if (e.source === e.target) continue;
if (g.hasEdge(e.source, e.target)) continue;
g.addEdge(e.source, e.target);
}
// graphology-communities-louvain returns Record<nodeId, communityId>
const result = louvain(g) as Record<string, number>;
const map = new Map<string, number>();
for (const id of nodeIds) {
map.set(id, result[id] ?? -1);
}
// Defensive: reassign any -1 sentinels to unique ids past the max.
// See the JSDoc on detectCommunities for why this is kept despite the
// current library already producing unique ids for disconnected nodes.
let next =
Math.max(...Array.from(map.values()).filter((v) => v >= 0), -1) + 1;
for (const [id, c] of map) {
if (c === -1) {
map.set(id, next++);
}
}
return map;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfoFile",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

View File

@@ -0,0 +1,49 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
base: "/demo/",
resolve: {
alias: {
"@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"),
"@understand-anything/core/search": path.resolve(__dirname, "../core/dist/search.js"),
"@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"),
},
},
define: {
"import.meta.env.VITE_DEMO_MODE": JSON.stringify("true"),
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return;
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) {
return "react-vendor";
}
if (id.includes("node_modules/@xyflow/")) return "xyflow";
if (
id.includes("node_modules/@dagrejs/") ||
id.includes("node_modules/d3-force/")
) {
return "graph-layout";
}
if (
id.includes("node_modules/react-markdown/") ||
id.includes("node_modules/hast-util-to-jsx-runtime/") ||
/[\\/]node_modules[\\/](remark|rehype|mdast|hast|unist|micromark|decode-named-character-reference|property-information|space-separated-tokens|comma-separated-tokens|html-url-attributes|devlop|bail|ccount|character-entities|is-plain-obj|trim-lines|trough|unified|vfile|zwitch)/.test(id)
) {
return "markdown";
}
},
},
},
},
plugins: [react(), tailwindcss()],
});

View File

@@ -0,0 +1,362 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
import fs from "fs";
import crypto from "crypto";
// Generate a one-time token when the server process starts.
// This token is printed to the terminal and must be in the URL
// to fetch knowledge-graph.json or diff-overlay.json.
const ACCESS_TOKEN = process.env.UNDERSTAND_ACCESS_TOKEN || crypto.randomBytes(16).toString("hex");
const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
function graphFileCandidates(fileName: string): string[] {
const graphDir = process.env.GRAPH_DIR;
return [
...(graphDir
? [path.resolve(graphDir, `.understand-anything/${fileName}`)]
: []),
path.resolve(process.cwd(), `.understand-anything/${fileName}`),
path.resolve(process.cwd(), `../../../.understand-anything/${fileName}`),
];
}
function findGraphFile(fileName: string): string | null {
return graphFileCandidates(fileName).find((candidate) => fs.existsSync(candidate)) ?? null;
}
function projectRootFromGraphFile(candidate: string): string {
return path.dirname(path.dirname(candidate));
}
function normalizeGraphPath(filePath: string, projectRoot: string): string | null {
const rawPath = path.isAbsolute(filePath)
? filePath.startsWith(projectRoot)
? path.relative(projectRoot, filePath)
: null
: filePath;
if (rawPath === null) return null;
const normalized = path.normalize(rawPath);
if (
!normalized ||
normalized === "." ||
normalized.includes("\0") ||
normalized === ".." ||
normalized.startsWith(`..${path.sep}`) ||
path.isAbsolute(normalized)
) {
return null;
}
return normalized.split(path.sep).join("/");
}
function graphFilePathSet(graphFile: string, projectRoot: string): Set<string> {
const allowed = new Set<string>();
try {
const raw = JSON.parse(fs.readFileSync(graphFile, "utf-8")) as {
nodes?: Array<Record<string, unknown>>;
};
for (const node of raw.nodes ?? []) {
if (typeof node.filePath !== "string") continue;
const normalized = normalizeGraphPath(node.filePath, projectRoot);
if (normalized) allowed.add(normalized);
}
} catch {
return allowed;
}
return allowed;
}
function detectLanguage(filePath: string): string {
const ext = path.extname(filePath).slice(1).toLowerCase();
const byExt: Record<string, string> = {
bash: "bash",
c: "c",
cc: "cpp",
cpp: "cpp",
cs: "csharp",
css: "css",
go: "go",
h: "c",
hpp: "cpp",
html: "markup",
java: "java",
js: "javascript",
jsx: "jsx",
json: "json",
md: "markdown",
mjs: "javascript",
py: "python",
rb: "ruby",
rs: "rust",
sh: "bash",
ts: "typescript",
tsx: "tsx",
txt: "text",
yaml: "yaml",
yml: "yaml",
};
return byExt[ext] ?? "text";
}
function sendJson(res: import("http").ServerResponse, statusCode: number, payload: unknown) {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(payload));
}
function rejectFileRequest(message: string, statusCode = 400) {
return { statusCode, payload: { error: message } };
}
function readSourceFile(url: URL) {
const requestedPath = url.searchParams.get("path") ?? "";
if (!requestedPath) return rejectFileRequest("Missing path");
if (requestedPath.includes("\0")) return rejectFileRequest("Invalid path");
if (path.isAbsolute(requestedPath)) return rejectFileRequest("Absolute paths are not allowed");
const normalizedPath = path.normalize(requestedPath);
if (
normalizedPath === "." ||
normalizedPath.startsWith(`..${path.sep}`) ||
normalizedPath === ".." ||
path.isAbsolute(normalizedPath)
) {
return rejectFileRequest("Path must stay inside the project");
}
const graphFile = findGraphFile("knowledge-graph.json");
if (!graphFile) {
return rejectFileRequest("No knowledge graph found. Run /understand first.", 404);
}
const projectRoot = projectRootFromGraphFile(graphFile);
const absoluteFile = path.resolve(projectRoot, normalizedPath);
const relativeToRoot = path.relative(projectRoot, absoluteFile);
if (
!relativeToRoot ||
relativeToRoot.startsWith(`..${path.sep}`) ||
relativeToRoot === ".." ||
path.isAbsolute(relativeToRoot)
) {
return rejectFileRequest("Path must stay inside the project");
}
const safeRelativePath = relativeToRoot.split(path.sep).join("/");
if (!graphFilePathSet(graphFile, projectRoot).has(safeRelativePath)) {
return rejectFileRequest("File is not in the knowledge graph", 404);
}
let stat: fs.Stats;
try {
stat = fs.statSync(absoluteFile);
} catch {
return rejectFileRequest("File not found", 404);
}
if (!stat.isFile()) return rejectFileRequest("Path is not a file");
if (stat.size > MAX_SOURCE_FILE_BYTES) {
return rejectFileRequest("File is too large to preview", 413);
}
const buffer = fs.readFileSync(absoluteFile);
if (buffer.includes(0)) return rejectFileRequest("Binary files cannot be previewed", 415);
const content = buffer.toString("utf8");
return {
statusCode: 200,
payload: {
path: safeRelativePath,
language: detectLanguage(relativeToRoot),
content,
sizeBytes: buffer.byteLength,
lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length,
},
};
}
export default defineConfig({
test: {
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
},
// FIX 1 — bind only to localhost, not 0.0.0.0
// This blocks access from any other device on the same LAN / WiFi.
server: {
host: "127.0.0.1",
port: 5173,
open: `/?token=${ACCESS_TOKEN}`,
},
resolve: {
alias: {
"@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"),
"@understand-anything/core/search": path.resolve(__dirname, "../core/dist/search.js"),
"@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"),
},
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return;
if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) {
return "react-vendor";
}
if (id.includes("node_modules/@xyflow/")) return "xyflow";
// ELK is ~1.6MB raw — split into its own chunk so it doesn't
// bloat the main bundle. graphology is similarly large.
if (id.includes("node_modules/elkjs/")) return "elk";
if (id.includes("node_modules/graphology")) return "graphology";
if (
id.includes("node_modules/@dagrejs/") ||
id.includes("node_modules/d3-force/")
) {
return "graph-layout";
}
if (
id.includes("node_modules/react-markdown/") ||
id.includes("node_modules/hast-util-to-jsx-runtime/") ||
/[\\/]node_modules[\\/](remark|rehype|mdast|hast|unist|micromark|decode-named-character-reference|property-information|space-separated-tokens|comma-separated-tokens|html-url-attributes|devlop|bail|ccount|character-entities|is-plain-obj|trim-lines|trough|unified|vfile|zwitch)/.test(id)
) {
return "markdown";
}
},
},
},
},
plugins: [
react(),
tailwindcss(),
{
name: "serve-knowledge-graph",
configureServer(server) {
// Print the access URL once so the developer can open it.
server.httpServer?.once("listening", () => {
const address = server.httpServer?.address();
const port = typeof address === "object" && address ? address.port : 5173;
console.log(
`\n 🔑 Dashboard URL: http://127.0.0.1:${port}/?token=${ACCESS_TOKEN}\n`
);
});
server.middlewares.use((req, res, next) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1:5173");
const pathname = url.pathname;
const isProtectedEndpoint =
pathname === "/knowledge-graph.json" ||
pathname === "/domain-graph.json" ||
pathname === "/diff-overlay.json" ||
pathname === "/meta.json" ||
pathname === "/config.json" ||
pathname === "/file-content.json";
if (!isProtectedEndpoint) {
next();
return;
}
// FIX 3 — require the one-time token on all data endpoints.
// Requests without a matching ?token= get a 403.
if (url.searchParams.get("token") !== ACCESS_TOKEN) {
sendJson(res, 403, { error: "Forbidden: missing or invalid token" });
return;
}
if (pathname === "/file-content.json") {
const result = readSourceFile(url);
sendJson(res, result.statusCode, result.payload);
return;
}
if (pathname === "/config.json") {
const configCandidates = graphFileCandidates("config.json");
for (const candidate of configCandidates) {
if (fs.existsSync(candidate)) {
try {
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8"));
sendJson(res, 200, raw);
return;
} catch {
sendJson(res, 500, { error: "Failed to read config file" });
return;
}
}
}
sendJson(res, 200, { autoUpdate: false, outputLanguage: "en" });
return;
}
const fileName =
pathname === "/diff-overlay.json"
? "diff-overlay.json"
: pathname === "/meta.json"
? "meta.json"
: pathname === "/domain-graph.json"
? "domain-graph.json"
: "knowledge-graph.json";
const candidates = graphFileCandidates(fileName);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
// FIX 2 — sanitise absolute file paths before sending the JSON.
// Nodes can contain filePath values like /Users/alice/company/src/auth.ts.
// We convert those to relative paths (src/auth.ts) so the developer's
// home directory and company directory layout are not leaked.
try {
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")) as {
nodes?: Array<Record<string, unknown>>;
[key: string]: unknown;
};
// Derive the project root from the candidate path so we can
// make file paths relative to it.
const projectRoot = projectRootFromGraphFile(candidate);
if (Array.isArray(raw.nodes)) {
raw.nodes = raw.nodes.map((node) => {
if (typeof node.filePath !== "string") return node;
const abs = node.filePath;
// Only relativise paths that actually sit inside projectRoot.
// Leave external or already-relative paths untouched.
const rel = abs.startsWith(projectRoot)
? abs.slice(projectRoot.length).replace(/^[\\/]/, "")
: path.isAbsolute(abs)
? path.basename(abs) // absolute but outside root — use filename only
: abs; // already relative — keep as-is
return { ...node, filePath: rel };
});
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(raw));
} catch (err) {
// If we cannot parse or sanitise the file, refuse to serve it
// rather than accidentally leaking raw content.
console.error("[understand-anything] Failed to sanitise graph file:", err);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Failed to read graph file" }));
}
return;
}
// No matching file found on disk.
res.statusCode = 404;
if (pathname === "/knowledge-graph.json") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "No knowledge graph found. Run /understand first." }));
} else {
res.end();
}
});
},
},
],
});