Add under-anything knowledge dashboard
This commit is contained in:
@@ -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>
|
||||
@@ -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 |
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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}`);
|
||||
});
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/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));
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">🧭</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} · {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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 URL;RoutIn 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">🔑</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(" + ");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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(グラフを書き出す)、Path(2 つのノード間のパスを検索)、Theme(テーマ切替)もあります。Shift + ? で全キーボードショートカットを確認できます。",
|
||||
hint: "必要になったときに開けば十分。一度に覚える必要はありません",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default ja;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Binary file not shown.
@@ -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] };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
1
Understand-Anything-main/understand-anything-plugin/packages/dashboard/src/vite-env.d.ts
vendored
Normal file
1
Understand-Anything-main/understand-anything-plugin/packages/dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user