ltcai 3.4.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -247
- package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
- package/docs/CHANGELOG.md +32 -0
- package/docs/HANDOVER_v3.6.0.md +46 -0
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
- package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
- package/docs/architecture.md +13 -12
- package/docs/kg-schema.md +55 -0
- package/docs/privacy.md +18 -2
- package/docs/security-model.md +17 -0
- package/kg_schema.py +46 -0
- package/knowledge_graph.py +520 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/auth.py +37 -9
- package/latticeai/api/browser.py +217 -0
- package/latticeai/api/chat.py +4 -1
- package/latticeai/api/computer_use.py +21 -8
- package/latticeai/api/portability.py +93 -0
- package/latticeai/api/tools.py +29 -26
- package/latticeai/core/config.py +3 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/oidc.py +205 -0
- package/latticeai/core/security.py +59 -5
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +39 -0
- package/latticeai/services/ingestion.py +271 -0
- package/latticeai/services/kg_portability.py +177 -0
- package/package.json +5 -4
- package/requirements.txt +1 -0
- package/scripts/build_vsix.mjs +72 -0
- package/scripts/check_python.py +87 -0
- package/static/css/reference/account.css +1 -1
- package/static/css/reference/admin.css +1 -1
- package/static/css/reference/base.css +8 -5
- package/static/css/reference/chat.css +8 -8
- package/static/css/reference/graph.css +2 -2
- package/static/css/responsive.css +2 -2
- package/static/v3/asset-manifest.json +9 -9
- package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
- package/static/v3/css/lattice.shell.css +2 -1
- package/static/v3/js/{app.d086489d.js → app.c541f955.js} +1 -1
- package/static/v3/js/core/{api.12b568ad.js → api.33d6320e.js} +38 -0
- package/static/v3/js/core/api.js +38 -0
- package/static/v3/js/core/{routes.d214b399.js → routes.2ce3815a.js} +1 -1
- package/static/v3/js/core/routes.js +1 -1
- package/static/v3/js/core/{shell.d05266f5.js → shell.8c163e0e.js} +2 -2
- package/static/v3/js/views/knowledge-graph.a96040a5.js +513 -0
- package/static/v3/js/views/knowledge-graph.js +293 -17
- package/static/workspace.css +1 -1
- package/tools/__init__.py +276 -0
- package/tools/commands.py +188 -0
- package/tools/computer.py +185 -0
- package/tools/documents.py +243 -0
- package/tools/filesystem.py +560 -0
- package/tools/knowledge.py +97 -0
- package/tools/local_files.py +69 -0
- package/tools/network.py +66 -0
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
- package/tools.py +0 -1525
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Local filesystem tools (require user approval via the UI) + desktop bridge status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from tools import ToolError, LOCAL_MAX_FILE_BYTES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def local_list(path: str) -> Dict[str, Any]:
|
|
12
|
+
"""List any directory on the local filesystem (requires user approval via UI)."""
|
|
13
|
+
target = Path(path).expanduser().resolve()
|
|
14
|
+
if not target.exists():
|
|
15
|
+
raise ToolError(f"경로가 존재하지 않습니다: {path}")
|
|
16
|
+
if not target.is_dir():
|
|
17
|
+
raise ToolError(f"폴더가 아닙니다: {path}")
|
|
18
|
+
items = []
|
|
19
|
+
try:
|
|
20
|
+
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
21
|
+
stat = child.stat()
|
|
22
|
+
items.append({
|
|
23
|
+
"name": child.name,
|
|
24
|
+
"path": str(child),
|
|
25
|
+
"type": "directory" if child.is_dir() else "file",
|
|
26
|
+
"size": stat.st_size if child.is_file() else None,
|
|
27
|
+
})
|
|
28
|
+
except PermissionError as exc:
|
|
29
|
+
raise ToolError(f"접근 권한 없음: {exc}") from exc
|
|
30
|
+
return {"path": str(target), "items": items}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_read(path: str) -> Dict[str, Any]:
|
|
34
|
+
"""Read any file on the local filesystem (requires user approval via UI)."""
|
|
35
|
+
target = Path(path).expanduser().resolve()
|
|
36
|
+
if not target.exists():
|
|
37
|
+
raise ToolError(f"파일이 존재하지 않습니다: {path}")
|
|
38
|
+
if not target.is_file():
|
|
39
|
+
raise ToolError(f"파일이 아닙니다: {path}")
|
|
40
|
+
size = target.stat().st_size
|
|
41
|
+
if size > LOCAL_MAX_FILE_BYTES:
|
|
42
|
+
raise ToolError(f"파일이 너무 큽니다 ({size:,} bytes). 최대 {LOCAL_MAX_FILE_BYTES:,} bytes.")
|
|
43
|
+
try:
|
|
44
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
raise ToolError(f"파일 읽기 실패: {exc}") from exc
|
|
47
|
+
return {"path": str(target), "size": size, "content": content}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def local_write(path: str, content: str) -> Dict[str, Any]:
|
|
51
|
+
"""Write content to any path on the local filesystem (requires user approval via UI)."""
|
|
52
|
+
target = Path(path).expanduser().resolve()
|
|
53
|
+
if len(content.encode("utf-8")) > LOCAL_MAX_FILE_BYTES:
|
|
54
|
+
raise ToolError("내용이 너무 큽니다.")
|
|
55
|
+
try:
|
|
56
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
target.write_text(content, encoding="utf-8")
|
|
58
|
+
except PermissionError as exc:
|
|
59
|
+
raise ToolError(f"쓰기 권한 없음: {exc}") from exc
|
|
60
|
+
return {"path": str(target), "bytes": target.stat().st_size}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def desktop_bridge_status() -> Dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"status": "requires_desktop_bridge",
|
|
67
|
+
"available_in_codex": True,
|
|
68
|
+
"note": "Chrome and Mac UI control require the Codex desktop Computer Use/Chrome bridge, not a headless FastAPI worker.",
|
|
69
|
+
}
|
package/tools/network.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Network status probe (no shell, short timeouts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from tools import ToolError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_network_command(parts: List[str], timeout: int = 5) -> str:
|
|
13
|
+
try:
|
|
14
|
+
completed = subprocess.run(parts, capture_output=True, text=True, timeout=timeout, check=False)
|
|
15
|
+
if completed.returncode != 0:
|
|
16
|
+
return ""
|
|
17
|
+
return completed.stdout.strip()
|
|
18
|
+
except Exception:
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def network_status() -> Dict[str, Any]:
|
|
23
|
+
"""현재 Mac의 내부 IP, 외부 IP, 주요 네트워크 정보를 반환합니다."""
|
|
24
|
+
local_ips: Dict[str, str] = {}
|
|
25
|
+
for interface in ["en0", "en1", "bridge100"]:
|
|
26
|
+
value = _run_network_command(["ipconfig", "getifaddr", interface])
|
|
27
|
+
if value:
|
|
28
|
+
local_ips[interface] = value
|
|
29
|
+
|
|
30
|
+
ifconfig_text = _run_network_command(["ifconfig"])
|
|
31
|
+
current_interface = ""
|
|
32
|
+
for line in ifconfig_text.splitlines():
|
|
33
|
+
if line and not line.startswith(("\t", " ")):
|
|
34
|
+
current_interface = line.split(":", 1)[0]
|
|
35
|
+
continue
|
|
36
|
+
match = re.search(r"\binet\s+(\d+\.\d+\.\d+\.\d+)\b", line)
|
|
37
|
+
if match and current_interface and match.group(1) != "127.0.0.1":
|
|
38
|
+
local_ips.setdefault(current_interface, match.group(1))
|
|
39
|
+
|
|
40
|
+
hostname = socket.gethostname()
|
|
41
|
+
guessed_ip = ""
|
|
42
|
+
try:
|
|
43
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
44
|
+
sock.connect(("8.8.8.8", 80))
|
|
45
|
+
guessed_ip = sock.getsockname()[0]
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
if guessed_ip and guessed_ip not in local_ips.values():
|
|
49
|
+
local_ips["default_route"] = guessed_ip
|
|
50
|
+
|
|
51
|
+
public_ip = _run_network_command(["curl", "-sS", "--max-time", "3", "https://api.ipify.org"])
|
|
52
|
+
wifi_info = _run_network_command(["networksetup", "-getinfo", "Wi-Fi"])
|
|
53
|
+
|
|
54
|
+
primary_local_ip = local_ips.get("en0") or local_ips.get("en1") or guessed_ip or ""
|
|
55
|
+
return {
|
|
56
|
+
"hostname": hostname,
|
|
57
|
+
"local_ip": primary_local_ip,
|
|
58
|
+
"local_ips": local_ips,
|
|
59
|
+
"public_ip": public_ip,
|
|
60
|
+
"wifi_info": wifi_info,
|
|
61
|
+
"ifconfig_available": bool(ifconfig_text),
|
|
62
|
+
"note": "local_ip은 같은 네트워크 안에서 보이는 내부 IP이고, public_ip는 인터넷에서 보이는 외부 IP입니다.",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_BLOCKED_FIND_FLAGS = {"-exec", "-execdir", "-delete", "-ok", "-okdir"}
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/* ============================================================================
|
|
2
|
-
* View: Knowledge Graph — entity/relation explorer.
|
|
3
|
-
* Renders the graph as an SVG mesh against /api/graph with a live inspector.
|
|
4
|
-
* Missing graph data renders an empty unavailable state.
|
|
5
|
-
* ========================================================================== */
|
|
6
|
-
|
|
7
|
-
import { escapeHtml } from "../core/dom.a2773eb0.js";
|
|
8
|
-
|
|
9
|
-
const TYPE_COLOR = {
|
|
10
|
-
Topic: "var(--lt3-pillar-graph)",
|
|
11
|
-
Concept: "var(--lt3-pillar-vector)",
|
|
12
|
-
Method: "var(--lt3-pillar-hybrid)",
|
|
13
|
-
Model: "var(--accent-3)",
|
|
14
|
-
File: "var(--faint)",
|
|
15
|
-
Decision: "var(--accent-3)",
|
|
16
|
-
Task: "var(--accent-2)",
|
|
17
|
-
Person: "var(--accent-pink)",
|
|
18
|
-
default: "var(--accent)",
|
|
19
|
-
};
|
|
20
|
-
const colorFor = (t) => TYPE_COLOR[t] || TYPE_COLOR.default;
|
|
21
|
-
|
|
22
|
-
export async function render(ctx) {
|
|
23
|
-
const { h, icon, api, store, c } = ctx;
|
|
24
|
-
|
|
25
|
-
const state = { selected: null, query: "", data: { nodes: [], edges: [] }, source: "pending" };
|
|
26
|
-
|
|
27
|
-
const canvasHost = h("div", c.loading({ lines: 0, block: true }));
|
|
28
|
-
const inspectorHost = h("div", c.loading({ lines: 4 }));
|
|
29
|
-
const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
|
|
30
|
-
const srcSlot = h("span", c.sourceBadge("pending"));
|
|
31
|
-
|
|
32
|
-
const root = h("div.lt3-stack-6",
|
|
33
|
-
c.viewHeader({
|
|
34
|
-
eyebrow: "Retrieval · structure",
|
|
35
|
-
title: "Knowledge Graph",
|
|
36
|
-
sub: "Entities and the relations the workspace extracted between them. Click a node to trace its neighborhood.",
|
|
37
|
-
actions: [
|
|
38
|
-
srcSlot,
|
|
39
|
-
h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => load() } }, icon("refresh"), "Rebuild view"),
|
|
40
|
-
h("button.lt3-btn.lt3-btn--primary", { on: { click: () => ctx.navigate("hybrid-search") } }, icon("arrows-join"), "Search graph"),
|
|
41
|
-
],
|
|
42
|
-
}),
|
|
43
|
-
statHost,
|
|
44
|
-
h("div.lt3-split",
|
|
45
|
-
h("div.lt3-stack-3",
|
|
46
|
-
c.card(canvasHost, { attrs: { style: "padding:0;overflow:hidden" } }),
|
|
47
|
-
buildLegend(ctx),
|
|
48
|
-
),
|
|
49
|
-
h("aside.lt3-panel",
|
|
50
|
-
h("div.lt3-panel__head", h("div", h("div.lt3-eyebrow", "Inspector"), h("h3.lt3-panel__title", "Entities"))),
|
|
51
|
-
h("div.lt3-search", { style: { "margin-bottom": "var(--lt3-space-4)" } },
|
|
52
|
-
icon("search"),
|
|
53
|
-
h("input", { type: "text", placeholder: "Filter entities…", "aria-label": "Filter entities",
|
|
54
|
-
on: { input: (e) => { state.query = e.target.value.toLowerCase(); renderInspector(); } } }),
|
|
55
|
-
),
|
|
56
|
-
inspectorHost,
|
|
57
|
-
),
|
|
58
|
-
),
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
async function load() {
|
|
62
|
-
canvasHost.replaceChildren(c.loading({ lines: 0, block: true }));
|
|
63
|
-
const [g, stats] = await Promise.all([api.graph(), api.graphStats()]);
|
|
64
|
-
state.data = normalize(g.data);
|
|
65
|
-
state.source = g.source;
|
|
66
|
-
srcSlot.replaceChildren(c.sourceBadge(g.source));
|
|
67
|
-
renderStats(stats.data, g.data);
|
|
68
|
-
renderCanvas();
|
|
69
|
-
renderInspector();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function renderStats(stats, graphData) {
|
|
73
|
-
const nodes = state.data.nodes.length;
|
|
74
|
-
const edges = state.data.edges.length;
|
|
75
|
-
const types = stats && stats.nodes ? Object.keys(stats.nodes).length : new Set(state.data.nodes.map((n) => n.type)).size;
|
|
76
|
-
const density = nodes > 1 ? (edges / (nodes * (nodes - 1) / 2)) : 0;
|
|
77
|
-
statHost.replaceChildren(
|
|
78
|
-
c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
|
|
79
|
-
c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
|
|
80
|
-
c.stat({ label: "Entity types", value: types, icon: "category" }),
|
|
81
|
-
c.stat({ label: "Density", value: density.toFixed(2), icon: "chart-dots" }),
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function renderCanvas() {
|
|
86
|
-
const { nodes, edges } = state.data;
|
|
87
|
-
if (!nodes.length) { canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." })); return; }
|
|
88
|
-
const laidOut = layout(nodes);
|
|
89
|
-
const pos = Object.fromEntries(laidOut.map((n) => [n.id, n]));
|
|
90
|
-
const W = 1000, H = 600;
|
|
91
|
-
const edgeSvg = edges.map((e) => {
|
|
92
|
-
const a = pos[e.from], b = pos[e.to];
|
|
93
|
-
if (!a || !b) return "";
|
|
94
|
-
return `<line class="lt3-gedge" x1="${a.px}" y1="${a.py}" x2="${b.px}" y2="${b.py}" stroke-width="${1 + (e.weight || 1) * 0.6}"></line>`;
|
|
95
|
-
}).join("");
|
|
96
|
-
const nodeSvg = laidOut.map((n) => {
|
|
97
|
-
const r = 10 + (n.weight || 0.5) * 16;
|
|
98
|
-
const sel = state.selected === n.id;
|
|
99
|
-
return `<g class="lt3-gnode" data-id="${escapeHtml(n.id)}" opacity="${state.selected && !sel && !isNeighbor(n.id) ? 0.35 : 1}">
|
|
100
|
-
<circle cx="${n.px}" cy="${n.py}" r="${sel ? r + 3 : r}" fill="${colorFor(n.type)}" stroke-width="${sel ? 3 : 2}"></circle>
|
|
101
|
-
<text x="${n.px}" y="${n.py + r + 13}" text-anchor="middle">${escapeHtml(truncate(n.label, 18))}</text>
|
|
102
|
-
</g>`;
|
|
103
|
-
}).join("");
|
|
104
|
-
canvasHost.replaceChildren(
|
|
105
|
-
h("div.lt3-graph-canvas", {
|
|
106
|
-
html: `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Knowledge graph">${edgeSvg}${nodeSvg}</svg>`,
|
|
107
|
-
on: { click: onCanvasClick },
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function onCanvasClick(e) {
|
|
113
|
-
const g = e.target.closest(".lt3-gnode");
|
|
114
|
-
if (!g) return;
|
|
115
|
-
state.selected = g.dataset.id === state.selected ? null : g.dataset.id;
|
|
116
|
-
renderCanvas();
|
|
117
|
-
renderInspector();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function isNeighbor(id) {
|
|
121
|
-
if (!state.selected) return false;
|
|
122
|
-
return state.data.edges.some((e) =>
|
|
123
|
-
(e.from === state.selected && e.to === id) || (e.to === state.selected && e.from === id));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function renderInspector() {
|
|
127
|
-
if (state.selected) { inspectorHost.replaceChildren(detailView()); return; }
|
|
128
|
-
const q = state.query;
|
|
129
|
-
const list = state.data.nodes
|
|
130
|
-
.filter((n) => !q || (n.label || "").toLowerCase().includes(q) || (n.type || "").toLowerCase().includes(q))
|
|
131
|
-
.sort((a, b) => (b.weight || 0) - (a.weight || 0));
|
|
132
|
-
inspectorHost.replaceChildren(
|
|
133
|
-
list.length
|
|
134
|
-
? h("div.lt3-stack-2", list.slice(0, 60).map((n) => entityRow(n)))
|
|
135
|
-
: c.emptyState({ icon: "search-off", title: "No matches", body: "Try a different entity name." }),
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function entityRow(n) {
|
|
140
|
-
return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; renderCanvas(); renderInspector(); } } },
|
|
141
|
-
h("div.lt3-entity__type", { style: { background: `color-mix(in srgb, ${colorFor(n.type)} 18%, transparent)`, color: colorFor(n.type) } }, icon(iconForType(n.type))),
|
|
142
|
-
h("div.lt3-entity__body",
|
|
143
|
-
h("div.lt3-entity__name", n.label),
|
|
144
|
-
h("div.lt3-entity__meta", `${n.type || "Entity"} · weight ${(n.weight || 0).toFixed(2)}`),
|
|
145
|
-
),
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function detailView() {
|
|
150
|
-
const n = state.data.nodes.find((x) => x.id === state.selected);
|
|
151
|
-
if (!n) { state.selected = null; return c.emptyState({ title: "Not found" }); }
|
|
152
|
-
const rels = state.data.edges
|
|
153
|
-
.filter((e) => e.from === n.id || e.to === n.id)
|
|
154
|
-
.map((e) => {
|
|
155
|
-
const otherId = e.from === n.id ? e.to : e.from;
|
|
156
|
-
const other = state.data.nodes.find((x) => x.id === otherId);
|
|
157
|
-
return { type: e.type, dir: e.from === n.id ? "→" : "←", other };
|
|
158
|
-
})
|
|
159
|
-
.filter((r) => r.other);
|
|
160
|
-
return h("div.lt3-stack-4",
|
|
161
|
-
h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; renderCanvas(); renderInspector(); } } }, icon("arrow-left"), "All entities"),
|
|
162
|
-
h("div.lt3-card.lt3-card--flat",
|
|
163
|
-
h("div.lt3-row-2", { style: { "margin-bottom": "var(--lt3-space-2)" } },
|
|
164
|
-
h("span.lt3-pill", { style: { color: colorFor(n.type) } }, n.type || "Entity"),
|
|
165
|
-
),
|
|
166
|
-
h("div", { style: { "font-size": "var(--lt3-text-lg)", "font-weight": 700 } }, n.label),
|
|
167
|
-
n.summary && h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-2)" } }, n.summary),
|
|
168
|
-
),
|
|
169
|
-
h("div",
|
|
170
|
-
h("div.lt3-eyebrow", { style: { "margin-bottom": "var(--lt3-space-2)" } }, `Relations (${rels.length})`),
|
|
171
|
-
rels.length
|
|
172
|
-
? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; renderCanvas(); renderInspector(); } } },
|
|
173
|
-
h("div.lt3-entity__type", { style: { background: "var(--surface-3)" } }, h("span.lt3-mono", { style: { "font-size": "var(--lt3-text-sm)" } }, r.dir)),
|
|
174
|
-
h("div.lt3-entity__body",
|
|
175
|
-
h("div.lt3-entity__name", r.other.label),
|
|
176
|
-
h("div.lt3-entity__meta", r.type),
|
|
177
|
-
),
|
|
178
|
-
)))
|
|
179
|
-
: c.emptyState({ icon: "unlink", title: "No relations", body: "This entity is currently isolated." }),
|
|
180
|
-
),
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
load();
|
|
185
|
-
return root;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/* ── helpers ─────────────────────────────────────────────────────────────── */
|
|
189
|
-
function normalize(data) {
|
|
190
|
-
const nodes = (data.nodes || []).map((n) => ({
|
|
191
|
-
id: n.id,
|
|
192
|
-
label: n.label || n.title || n.id,
|
|
193
|
-
type: n.type || "Entity",
|
|
194
|
-
weight: n.weight ?? n.importance_norm ?? (n.metadata && n.metadata.graph_metrics && n.metadata.graph_metrics.importance_norm) ?? 0.5,
|
|
195
|
-
summary: n.summary || "",
|
|
196
|
-
x: n.x, y: n.y,
|
|
197
|
-
}));
|
|
198
|
-
const ids = new Set(nodes.map((n) => n.id));
|
|
199
|
-
const edges = (data.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to))
|
|
200
|
-
.map((e) => ({ from: e.from, to: e.to, type: e.type || "related", weight: e.weight || 1 }));
|
|
201
|
-
return { nodes, edges };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function layout(nodes) {
|
|
205
|
-
const W = 1000, H = 600, cx = W / 2, cy = H / 2;
|
|
206
|
-
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
207
|
-
const hasCoords = nodes.length && nodes.every((n) => typeof n.x === "number" && typeof n.y === "number");
|
|
208
|
-
if (hasCoords) {
|
|
209
|
-
return nodes.map((n) => ({ ...n, px: Math.round(60 + n.x * (W - 120)), py: Math.round(50 + n.y * (H - 100)) }));
|
|
210
|
-
}
|
|
211
|
-
// Sunflower (Vogel) spread — even spacing, highest-weight entity centered.
|
|
212
|
-
const order = nodes.map((n, i) => ({ n, i })).sort((a, b) => (b.n.weight || 0) - (a.n.weight || 0));
|
|
213
|
-
const maxR = Math.min(W, H) * 0.42;
|
|
214
|
-
const placed = {};
|
|
215
|
-
order.forEach((o, rank) => {
|
|
216
|
-
const radius = rank === 0 ? 0 : maxR * Math.sqrt(rank / Math.max(1, nodes.length - 1));
|
|
217
|
-
const angle = rank * golden;
|
|
218
|
-
placed[o.i] = {
|
|
219
|
-
px: Math.round(cx + Math.cos(angle) * radius),
|
|
220
|
-
py: Math.round(cy + Math.sin(angle) * radius * 0.66),
|
|
221
|
-
};
|
|
222
|
-
});
|
|
223
|
-
return nodes.map((n, i) => ({ ...n, ...placed[i] }));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function truncate(s, n) { s = String(s || ""); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
|
227
|
-
|
|
228
|
-
function iconForType(t) {
|
|
229
|
-
return ({ Topic: "bulb", Concept: "atom", Method: "function", Model: "cpu", File: "file", Decision: "gavel", Task: "checkbox", Person: "user" })[t] || "point";
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function buildLegend({ h }) {
|
|
233
|
-
const types = ["Topic", "Concept", "Method", "Model", "File"];
|
|
234
|
-
return h("div.lt3-graph-legend",
|
|
235
|
-
types.map((t) => h("span", h("i", { style: { background: colorFor(t) } }), t)),
|
|
236
|
-
);
|
|
237
|
-
}
|