ltcai 1.5.0 → 1.7.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 +63 -33
- package/docs/CHANGELOG.md +89 -0
- package/docs/EDITION_STRATEGY.md +4 -0
- package/docs/ENTERPRISE.md +8 -2
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/tmp_frames/hero_00.png +0 -0
- package/docs/images/tmp_frames/hero_01.png +0 -0
- package/docs/images/tmp_frames/hero_02.png +0 -0
- package/docs/images/tmp_frames/hero_03.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/core/workspace_os.py +11 -1
- package/package.json +10 -1
- package/static/admin.html +62 -0
- package/static/graph.html +7 -1
- package/static/lattice-reference.css +184 -0
- package/static/scripts/admin.js +121 -1
- package/static/scripts/chat.js +28 -7
- package/static/scripts/graph.js +296 -14
- package/static/scripts/workspace.js +363 -24
- package/static/workspace.css +140 -0
- package/static/workspace.html +95 -2
package/static/scripts/graph.js
CHANGED
|
@@ -21,7 +21,10 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
21
21
|
local_indexed: '지식 그래프 생성 완료', local_watch_unavailable: '자동 감지는 watchdog 설치 후 작동합니다.',
|
|
22
22
|
detail_empty: '노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 볼 수 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.',
|
|
23
23
|
detail_empty_short: '노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.',
|
|
24
|
-
refresh: '새로고침',
|
|
24
|
+
refresh: '새로고침', fit: '맞춤', expand: '확장', collapse: '접기', focus: '포커스', path: '경로',
|
|
25
|
+
clear_focus: '포커스 해제', path_start: '경로 시작 지정', path_ready: '경로 시작: {title}', path_pick_target: '도착 노드를 선택한 뒤 경로를 누르세요.',
|
|
26
|
+
path_not_found: '두 노드 사이의 경로를 찾지 못했습니다.', source_open: '소스 열기',
|
|
27
|
+
error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
|
|
25
28
|
no_node_types: '아직 노드 유형이 없습니다.', no_relationships: '아직 관계가 없습니다.',
|
|
26
29
|
open_in_chat: '채팅에서 열기', today: '오늘', day_ago: '1일 전', days_ago: '{n}일 전', months_ago: '{n}개월 전', years_ago: '{n}년 전',
|
|
27
30
|
},
|
|
@@ -43,7 +46,10 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
43
46
|
local_indexed: 'Knowledge graph built', local_watch_unavailable: 'Auto watch works after watchdog is installed.',
|
|
44
47
|
detail_empty: 'Click a node to see its summary, importance, connection strength, and metadata. Search results can jump to more precise nodes.',
|
|
45
48
|
detail_empty_short: 'Click a node to see its summary, importance, and metadata.',
|
|
46
|
-
refresh: 'Refresh',
|
|
49
|
+
refresh: 'Refresh', fit: 'Fit', expand: 'Expand', collapse: 'Collapse', focus: 'Focus', path: 'Path',
|
|
50
|
+
clear_focus: 'Clear focus', path_start: 'Set path start', path_ready: 'Path start: {title}', path_pick_target: 'Select a destination node, then press Path.',
|
|
51
|
+
path_not_found: 'No path found between those nodes.', source_open: 'Open source',
|
|
52
|
+
error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
|
|
47
53
|
no_node_types: 'No node types yet.', no_relationships: 'No relationships yet.',
|
|
48
54
|
open_in_chat: 'Open in chat', today: 'today', day_ago: '1 day ago', days_ago: '{n} days ago', months_ago: '{n} mo ago', years_ago: '{n} yr ago',
|
|
49
55
|
}
|
|
@@ -73,7 +79,18 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
73
79
|
document.getElementById('local-source-label').textContent = t('local_sources');
|
|
74
80
|
document.getElementById('edge-label').textContent = t('relationship_legend');
|
|
75
81
|
document.getElementById('type-label').textContent = t('node_types');
|
|
76
|
-
|
|
82
|
+
const toolbarLabels = {
|
|
83
|
+
'refresh-btn': ['ti-refresh', t('refresh')],
|
|
84
|
+
'fit-btn': ['ti-arrows-maximize', t('fit')],
|
|
85
|
+
'expand-btn': ['ti-circle-plus', t('expand')],
|
|
86
|
+
'collapse-btn': ['ti-circle-minus', t('collapse')],
|
|
87
|
+
'focus-btn': ['ti-focus-2', focusNodeId ? t('clear_focus') : t('focus')],
|
|
88
|
+
'path-btn': ['ti-route', t('path')],
|
|
89
|
+
};
|
|
90
|
+
Object.entries(toolbarLabels).forEach(([id, [icon, label]]) => {
|
|
91
|
+
const btn = document.getElementById(id);
|
|
92
|
+
if (btn) btn.innerHTML = `<i class="ti ${icon}"></i> ${label}`;
|
|
93
|
+
});
|
|
77
94
|
const langBtn = document.getElementById('graph-lang-btn');
|
|
78
95
|
if (langBtn) langBtn.textContent = `Language: ${currentLang === 'ko' ? '한국어' : 'English'}`;
|
|
79
96
|
['ko', 'en'].forEach(lang => {
|
|
@@ -174,6 +191,13 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
174
191
|
let height = 0;
|
|
175
192
|
let searchResults = [];
|
|
176
193
|
let searchResultIds = new Set();
|
|
194
|
+
let expandedNodeIds = new Set();
|
|
195
|
+
let hiddenNodeIds = new Set();
|
|
196
|
+
let focusNodeId = null;
|
|
197
|
+
let focusDepth = 2;
|
|
198
|
+
let pathStartId = null;
|
|
199
|
+
let pathNodeIds = new Set();
|
|
200
|
+
let pathEdgeKeys = new Set();
|
|
177
201
|
let searchAbortController = null;
|
|
178
202
|
let searchDebounceId = null;
|
|
179
203
|
let localState = {
|
|
@@ -570,13 +594,55 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
570
594
|
return counts;
|
|
571
595
|
}
|
|
572
596
|
|
|
597
|
+
function edgeKey(edgeOrFrom, to) {
|
|
598
|
+
if (typeof edgeOrFrom === 'object') {
|
|
599
|
+
return `${edgeOrFrom.from}|${edgeOrFrom.to}`;
|
|
600
|
+
}
|
|
601
|
+
return `${edgeOrFrom}|${to}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function computeSubgraphIds(rootId, depth = 2) {
|
|
605
|
+
if (!rootId) return null;
|
|
606
|
+
const adjacency = new Map();
|
|
607
|
+
rawGraph.edges.forEach(edge => {
|
|
608
|
+
if (!edge.from || !edge.to) return;
|
|
609
|
+
if (!adjacency.has(edge.from)) adjacency.set(edge.from, new Set());
|
|
610
|
+
if (!adjacency.has(edge.to)) adjacency.set(edge.to, new Set());
|
|
611
|
+
adjacency.get(edge.from).add(edge.to);
|
|
612
|
+
adjacency.get(edge.to).add(edge.from);
|
|
613
|
+
});
|
|
614
|
+
const visible = new Set([rootId]);
|
|
615
|
+
let frontier = new Set([rootId]);
|
|
616
|
+
for (let i = 0; i < depth; i++) {
|
|
617
|
+
const next = new Set();
|
|
618
|
+
frontier.forEach(id => {
|
|
619
|
+
(adjacency.get(id) || []).forEach(neighbor => {
|
|
620
|
+
if (!visible.has(neighbor)) {
|
|
621
|
+
visible.add(neighbor);
|
|
622
|
+
next.add(neighbor);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
frontier = next;
|
|
627
|
+
}
|
|
628
|
+
pathNodeIds.forEach(id => visible.add(id));
|
|
629
|
+
return visible;
|
|
630
|
+
}
|
|
631
|
+
|
|
573
632
|
function applyFilter() {
|
|
574
|
-
|
|
633
|
+
const focusIds = computeSubgraphIds(focusNodeId, focusDepth);
|
|
634
|
+
graph.nodes = rawGraph.nodes.filter(node => {
|
|
635
|
+
if (hiddenTypes.has(node.type)) return false;
|
|
636
|
+
if (focusIds && !focusIds.has(node.id)) return false;
|
|
637
|
+
if (hiddenNodeIds.has(node.id) && node.id !== selected?.id && node.id !== focusNodeId && !pathNodeIds.has(node.id)) return false;
|
|
638
|
+
return true;
|
|
639
|
+
});
|
|
575
640
|
const nodeSet = new Set(graph.nodes.map(node => node.id));
|
|
576
641
|
const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
|
|
577
642
|
graph.edges = rawGraph.edges
|
|
578
643
|
.filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
|
|
579
644
|
.map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
|
|
645
|
+
renderFocusChip();
|
|
580
646
|
}
|
|
581
647
|
|
|
582
648
|
function seedLayout() {
|
|
@@ -698,8 +764,14 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
698
764
|
updateStats();
|
|
699
765
|
renderTypeFilters(stats.nodes || buildTypeCounts());
|
|
700
766
|
renderEdgeLegend(stats.edges || {});
|
|
701
|
-
|
|
767
|
+
const urlNode = new URLSearchParams(window.location.search).get('node');
|
|
768
|
+
const initialNode = (urlNode && rawGraph.nodes.find(node => node.id === urlNode))
|
|
769
|
+
|| (selected && rawGraph.nodes.find(node => node.id === selected.id))
|
|
770
|
+
|| graph.nodes[0]
|
|
771
|
+
|| null;
|
|
702
772
|
cam = { scale: 1, tx: 0, ty: 0 };
|
|
773
|
+
showDetail(initialNode);
|
|
774
|
+
if (initialNode && urlNode) centerOnNode(initialNode, Math.max(cam.scale, 1));
|
|
703
775
|
wakeUp();
|
|
704
776
|
}
|
|
705
777
|
|
|
@@ -755,6 +827,29 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
755
827
|
}
|
|
756
828
|
window.toggleType = toggleType;
|
|
757
829
|
|
|
830
|
+
function renderFocusChip() {
|
|
831
|
+
const chip = document.getElementById('graph-focus-chip');
|
|
832
|
+
if (!chip) return;
|
|
833
|
+
const focusNode = rawGraph.nodes.find(node => node.id === focusNodeId);
|
|
834
|
+
const pathStart = rawGraph.nodes.find(node => node.id === pathStartId);
|
|
835
|
+
const parts = [];
|
|
836
|
+
if (focusNode) parts.push(`<span><i class="ti ti-focus-2"></i>${escapeHtml(focusNode.title || focusNode.id)}</span>`);
|
|
837
|
+
if (pathStart) parts.push(`<span><i class="ti ti-route"></i>${escapeHtml(t('path_ready').replace('{title}', pathStart.title || pathStart.id))}</span>`);
|
|
838
|
+
if (pathNodeIds.size) parts.push(`<span>${pathNodeIds.size} nodes</span>`);
|
|
839
|
+
chip.hidden = parts.length === 0;
|
|
840
|
+
chip.innerHTML = parts.join('');
|
|
841
|
+
applyI18n();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function relatedNodeIds(nodeId) {
|
|
845
|
+
const ids = new Set();
|
|
846
|
+
rawGraph.edges.forEach(edge => {
|
|
847
|
+
if (edge.from === nodeId) ids.add(edge.to);
|
|
848
|
+
if (edge.to === nodeId) ids.add(edge.from);
|
|
849
|
+
});
|
|
850
|
+
return ids;
|
|
851
|
+
}
|
|
852
|
+
|
|
758
853
|
function step() {
|
|
759
854
|
const nodes = graph.nodes;
|
|
760
855
|
const edges = graph.edges;
|
|
@@ -841,13 +936,14 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
841
936
|
if (!edge.source || !edge.target) return;
|
|
842
937
|
const style = edgeStyle(edge.type);
|
|
843
938
|
const isNeighborEdge = neighborSet && neighborSet.has(edge.from) && neighborSet.has(edge.to);
|
|
844
|
-
const
|
|
845
|
-
const
|
|
939
|
+
const isPathEdge = pathEdgeKeys.has(edgeKey(edge)) || pathEdgeKeys.has(edgeKey(edge.to, edge.from));
|
|
940
|
+
const baseAlpha = isPathEdge ? 0.98 : (neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34);
|
|
941
|
+
const widthBoost = isPathEdge ? 2.2 : (isNeighborEdge ? 0.5 : 0);
|
|
846
942
|
ctx.save();
|
|
847
943
|
ctx.globalAlpha = baseAlpha;
|
|
848
|
-
ctx.strokeStyle = style.color;
|
|
944
|
+
ctx.strokeStyle = isPathEdge ? '#f59e0b' : style.color;
|
|
849
945
|
ctx.lineWidth = (style.width + Math.min(3.4, (edge.weight || 1) * 1.1) + widthBoost) / cam.scale;
|
|
850
|
-
ctx.setLineDash(style.dash || []);
|
|
946
|
+
ctx.setLineDash(isPathEdge ? [] : (style.dash || []));
|
|
851
947
|
ctx.beginPath();
|
|
852
948
|
ctx.moveTo(edge.source.x, edge.source.y);
|
|
853
949
|
ctx.lineTo(edge.target.x, edge.target.y);
|
|
@@ -858,10 +954,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
858
954
|
graph.nodes.forEach(node => {
|
|
859
955
|
const isNeighbor = neighborSet ? neighborSet.has(node.id) : true;
|
|
860
956
|
const isSearchHit = searchResultIds.has(node.id);
|
|
957
|
+
const isPathNode = pathNodeIds.has(node.id);
|
|
861
958
|
const isSelected = node === selected;
|
|
862
959
|
const isHovered = node === hovered;
|
|
863
960
|
const alpha = neighborSet ? (isNeighbor ? 1 : 0.12) : 1;
|
|
864
|
-
const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isSearchHit ? 2.6 : 0);
|
|
961
|
+
const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isPathNode ? 3.5 : isSearchHit ? 2.6 : 0);
|
|
865
962
|
|
|
866
963
|
ctx.globalAlpha = alpha;
|
|
867
964
|
|
|
@@ -890,9 +987,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
890
987
|
ctx.stroke();
|
|
891
988
|
|
|
892
989
|
// 선택/호버 외곽 링
|
|
893
|
-
if (isSelected || isHovered || isSearchHit) {
|
|
894
|
-
ctx.strokeStyle = isSelected ? '#6f42e8' : nodeColor(node.type);
|
|
895
|
-
ctx.lineWidth = (isSelected ? 2.8 : 1.8) / cam.scale;
|
|
990
|
+
if (isSelected || isHovered || isSearchHit || isPathNode) {
|
|
991
|
+
ctx.strokeStyle = isPathNode ? '#f59e0b' : (isSelected ? '#6f42e8' : nodeColor(node.type));
|
|
992
|
+
ctx.lineWidth = (isSelected || isPathNode ? 2.8 : 1.8) / cam.scale;
|
|
896
993
|
ctx.globalAlpha = alpha * 0.55;
|
|
897
994
|
ctx.beginPath();
|
|
898
995
|
ctx.arc(node.x, node.y, radius + 5 / cam.scale, 0, Math.PI * 2);
|
|
@@ -979,6 +1076,123 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
979
1076
|
wakeUp();
|
|
980
1077
|
}
|
|
981
1078
|
|
|
1079
|
+
async function expandNode(node = selected) {
|
|
1080
|
+
if (!node) return;
|
|
1081
|
+
const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(node.id)}`);
|
|
1082
|
+
if (!res.ok) throw new Error(`Expand failed (${res.status})`);
|
|
1083
|
+
const payload = await res.json();
|
|
1084
|
+
const nodes = [
|
|
1085
|
+
payload.node || node,
|
|
1086
|
+
...((payload.neighbors || []).map(item => ({ ...item, updated_at: item.updated_at }))),
|
|
1087
|
+
];
|
|
1088
|
+
expandedNodeIds.add(node.id);
|
|
1089
|
+
nodes.forEach(item => hiddenNodeIds.delete(item.id));
|
|
1090
|
+
mergeGraphData(nodes, payload.edges || []);
|
|
1091
|
+
showDetail(rawGraph.nodes.find(item => item.id === node.id) || node);
|
|
1092
|
+
centerOnNode(node, Math.max(cam.scale, 1));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function collapseNode(node = selected) {
|
|
1096
|
+
if (!node) return;
|
|
1097
|
+
relatedNodeIds(node.id).forEach(id => {
|
|
1098
|
+
if (id !== node.id && id !== focusNodeId && !pathNodeIds.has(id)) hiddenNodeIds.add(id);
|
|
1099
|
+
});
|
|
1100
|
+
expandedNodeIds.delete(node.id);
|
|
1101
|
+
applyFilter();
|
|
1102
|
+
showDetail(node);
|
|
1103
|
+
wakeUp();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function toggleFocus(node = selected) {
|
|
1107
|
+
if (!node) return;
|
|
1108
|
+
if (focusNodeId === node.id) {
|
|
1109
|
+
focusNodeId = null;
|
|
1110
|
+
} else {
|
|
1111
|
+
focusNodeId = node.id;
|
|
1112
|
+
hiddenNodeIds.delete(node.id);
|
|
1113
|
+
}
|
|
1114
|
+
applyFilter();
|
|
1115
|
+
centerOnNode(node, Math.max(cam.scale, 0.9));
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function localShortestPath(startId, targetId) {
|
|
1119
|
+
if (!startId || !targetId || startId === targetId) return startId ? [startId] : [];
|
|
1120
|
+
const adjacency = new Map();
|
|
1121
|
+
rawGraph.edges.forEach(edge => {
|
|
1122
|
+
if (!edge.from || !edge.to) return;
|
|
1123
|
+
if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
|
|
1124
|
+
if (!adjacency.has(edge.to)) adjacency.set(edge.to, []);
|
|
1125
|
+
adjacency.get(edge.from).push(edge.to);
|
|
1126
|
+
adjacency.get(edge.to).push(edge.from);
|
|
1127
|
+
});
|
|
1128
|
+
const queue = [[startId]];
|
|
1129
|
+
const seen = new Set([startId]);
|
|
1130
|
+
while (queue.length) {
|
|
1131
|
+
const path = queue.shift();
|
|
1132
|
+
const last = path[path.length - 1];
|
|
1133
|
+
if (last === targetId) return path;
|
|
1134
|
+
(adjacency.get(last) || []).forEach(next => {
|
|
1135
|
+
if (!seen.has(next)) {
|
|
1136
|
+
seen.add(next);
|
|
1137
|
+
queue.push([...path, next]);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
return [];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
async function showShortestPath(target = selected) {
|
|
1145
|
+
if (!target) return;
|
|
1146
|
+
if (!pathStartId) {
|
|
1147
|
+
pathStartId = target.id;
|
|
1148
|
+
renderFocusChip();
|
|
1149
|
+
showDetail(target);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
let path = localShortestPath(pathStartId, target.id);
|
|
1153
|
+
if (!path.length || path[path.length - 1] !== target.id) {
|
|
1154
|
+
const res = await apiFetch(`/workspace/relationships/${encodeURIComponent(pathStartId)}?target_id=${encodeURIComponent(target.id)}`);
|
|
1155
|
+
if (res.ok) {
|
|
1156
|
+
const payload = await res.json();
|
|
1157
|
+
path = Array.isArray(payload.shortest_path) ? payload.shortest_path : path;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (!path.length || path[path.length - 1] !== target.id) {
|
|
1161
|
+
searchCountEl.textContent = t('path_not_found');
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
pathNodeIds = new Set(path);
|
|
1165
|
+
pathEdgeKeys = new Set();
|
|
1166
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
1167
|
+
pathEdgeKeys.add(edgeKey(path[i], path[i + 1]));
|
|
1168
|
+
pathEdgeKeys.add(edgeKey(path[i + 1], path[i]));
|
|
1169
|
+
}
|
|
1170
|
+
path.forEach(id => hiddenNodeIds.delete(id));
|
|
1171
|
+
applyFilter();
|
|
1172
|
+
const first = rawGraph.nodes.find(node => node.id === path[0]);
|
|
1173
|
+
const last = rawGraph.nodes.find(node => node.id === path[path.length - 1]);
|
|
1174
|
+
if (first && last) {
|
|
1175
|
+
const x0 = Math.min(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.x ?? first.x));
|
|
1176
|
+
const x1 = Math.max(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.x ?? first.x));
|
|
1177
|
+
const y0 = Math.min(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.y ?? first.y));
|
|
1178
|
+
const y1 = Math.max(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.y ?? first.y));
|
|
1179
|
+
cam.scale = clamp(Math.min(width / Math.max(260, x1 - x0 + 180), height / Math.max(220, y1 - y0 + 160)), 0.4, 2.4);
|
|
1180
|
+
cam.tx = width / 2 - ((x0 + x1) / 2) * cam.scale;
|
|
1181
|
+
cam.ty = height / 2 - ((y0 + y1) / 2) * cam.scale;
|
|
1182
|
+
}
|
|
1183
|
+
showDetail(target);
|
|
1184
|
+
wakeUp();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function clearPath() {
|
|
1188
|
+
pathStartId = selected?.id || null;
|
|
1189
|
+
pathNodeIds = new Set();
|
|
1190
|
+
pathEdgeKeys = new Set();
|
|
1191
|
+
renderFocusChip();
|
|
1192
|
+
applyFilter();
|
|
1193
|
+
wakeUp();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
982
1196
|
function metricCards(node) {
|
|
983
1197
|
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
984
1198
|
const cards = [
|
|
@@ -1010,25 +1224,74 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1010
1224
|
selected = node;
|
|
1011
1225
|
const meta = node.metadata || {};
|
|
1012
1226
|
const convId = meta.conversation_id;
|
|
1227
|
+
const sourcePath = meta.path || meta.absolute_path || meta.root_path || meta.source_path || '';
|
|
1013
1228
|
const jumpHtml = convId
|
|
1014
1229
|
? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">${t('open_in_chat')}</a>`
|
|
1015
1230
|
: '';
|
|
1231
|
+
const sourceHtml = sourcePath
|
|
1232
|
+
? `<a class="jump-btn secondary" href="${API_BASE}/local/serve?path=${encodeURIComponent(sourcePath)}">${t('source_open')}</a>`
|
|
1233
|
+
: '';
|
|
1016
1234
|
const metrics = metricCards(node);
|
|
1017
1235
|
const updatedAt = formatUpdatedAt(node.updated_at);
|
|
1018
1236
|
const source = meta.relative_path || meta.filename || meta.conversation_id || meta.source || '';
|
|
1019
1237
|
const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
|
|
1238
|
+
const relatedRows = rawGraph.edges
|
|
1239
|
+
.filter(edge => edge.from === node.id || edge.to === node.id)
|
|
1240
|
+
.slice(0, 10)
|
|
1241
|
+
.map(edge => {
|
|
1242
|
+
const otherId = edge.from === node.id ? edge.to : edge.from;
|
|
1243
|
+
const other = rawGraph.nodes.find(item => item.id === otherId) || { id: otherId, title: otherId, type: 'Event' };
|
|
1244
|
+
const direction = edge.from === node.id ? '→' : '←';
|
|
1245
|
+
return `
|
|
1246
|
+
<button class="related-node-btn" data-detail-node="${escapeHtml(otherId)}">
|
|
1247
|
+
<span>${direction}</span>
|
|
1248
|
+
<strong>${escapeHtml(other.title || other.id)}</strong>
|
|
1249
|
+
<em>${escapeHtml(edge.type || 'related')}</em>
|
|
1250
|
+
</button>
|
|
1251
|
+
`;
|
|
1252
|
+
}).join('');
|
|
1020
1253
|
detail.innerHTML = `
|
|
1021
1254
|
<div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
|
|
1022
1255
|
<div class="detail-title">${escapeHtml(node.title || node.id)}</div>
|
|
1023
1256
|
${node.summary ? `<div class="detail-summary">${escapeHtml(node.summary)}</div>` : ''}
|
|
1024
|
-
|
|
1257
|
+
<div class="detail-actions">
|
|
1258
|
+
${jumpHtml}
|
|
1259
|
+
${sourceHtml}
|
|
1260
|
+
<button class="jump-btn secondary" data-graph-action="expand">${t('expand')}</button>
|
|
1261
|
+
<button class="jump-btn secondary" data-graph-action="focus">${focusNodeId === node.id ? t('clear_focus') : t('focus')}</button>
|
|
1262
|
+
<button class="jump-btn secondary" data-graph-action="path-start">${t('path_start')}</button>
|
|
1263
|
+
</div>
|
|
1025
1264
|
${metrics}
|
|
1026
1265
|
<div class="detail-summary">
|
|
1027
1266
|
${source ? `<strong>source:</strong> ${escapeHtml(source)}<br>` : ''}
|
|
1028
1267
|
${updatedAt ? `<strong>updated:</strong> ${escapeHtml(updatedAt)}` : ''}
|
|
1029
1268
|
</div>
|
|
1269
|
+
${relatedRows ? `<div class="related-node-list">${relatedRows}</div>` : ''}
|
|
1030
1270
|
${metadataStr ? `<div class="meta-block">${escapeHtml(metadataStr)}</div>` : ''}
|
|
1031
1271
|
`;
|
|
1272
|
+
detail.querySelectorAll('[data-graph-action]').forEach(btn => {
|
|
1273
|
+
btn.addEventListener('click', () => {
|
|
1274
|
+
const action = btn.dataset.graphAction;
|
|
1275
|
+
if (action === 'expand') expandNode(node).catch(error => { searchCountEl.textContent = error.message; });
|
|
1276
|
+
if (action === 'focus') toggleFocus(node);
|
|
1277
|
+
if (action === 'path-start') {
|
|
1278
|
+
pathStartId = node.id;
|
|
1279
|
+
pathNodeIds = new Set();
|
|
1280
|
+
pathEdgeKeys = new Set();
|
|
1281
|
+
renderFocusChip();
|
|
1282
|
+
wakeUp();
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
detail.querySelectorAll('[data-detail-node]').forEach(btn => {
|
|
1287
|
+
btn.addEventListener('click', () => {
|
|
1288
|
+
const next = rawGraph.nodes.find(item => item.id === btn.dataset.detailNode);
|
|
1289
|
+
if (next) {
|
|
1290
|
+
showDetail(next);
|
|
1291
|
+
centerOnNode(next, Math.max(cam.scale, 0.95));
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1032
1295
|
wakeUp();
|
|
1033
1296
|
}
|
|
1034
1297
|
|
|
@@ -1047,6 +1310,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1047
1310
|
}
|
|
1048
1311
|
|
|
1049
1312
|
function renderSearchResults() {
|
|
1313
|
+
document.querySelector('.search-shell')?.classList.toggle('search-open', Boolean(searchInput.value.trim()));
|
|
1050
1314
|
if (!searchInput.value.trim()) {
|
|
1051
1315
|
searchResultsEl.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
|
|
1052
1316
|
return;
|
|
@@ -1240,6 +1504,14 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1240
1504
|
wakeUp();
|
|
1241
1505
|
}, { passive: false });
|
|
1242
1506
|
|
|
1507
|
+
canvas.addEventListener('dblclick', event => {
|
|
1508
|
+
const rect = canvas.getBoundingClientRect();
|
|
1509
|
+
const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
1510
|
+
if (node) expandNode(node).catch(error => {
|
|
1511
|
+
searchCountEl.textContent = error.message;
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1243
1515
|
let lastTouchDistance = null;
|
|
1244
1516
|
canvas.addEventListener('touchstart', event => {
|
|
1245
1517
|
event.preventDefault();
|
|
@@ -1320,6 +1592,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1320
1592
|
});
|
|
1321
1593
|
|
|
1322
1594
|
document.getElementById('clear-search-btn').addEventListener('click', clearSearch);
|
|
1595
|
+
document.getElementById('fit-btn').addEventListener('click', fitToScreen);
|
|
1596
|
+
document.getElementById('expand-btn').addEventListener('click', () => expandNode().catch(error => { searchCountEl.textContent = error.message; }));
|
|
1597
|
+
document.getElementById('collapse-btn').addEventListener('click', () => collapseNode());
|
|
1598
|
+
document.getElementById('focus-btn').addEventListener('click', () => toggleFocus());
|
|
1599
|
+
document.getElementById('path-btn').addEventListener('click', () => showShortestPath().catch(error => { searchCountEl.textContent = error.message; }));
|
|
1323
1600
|
document.addEventListener('click', event => {
|
|
1324
1601
|
if (!event.target.closest('.lang-picker')) {
|
|
1325
1602
|
document.querySelectorAll('.lang-picker-menu').forEach(menu => menu.classList.remove('open'));
|
|
@@ -1329,6 +1606,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1329
1606
|
rawGraph = { nodes: [], edges: [] };
|
|
1330
1607
|
graph = { nodes: [], edges: [] };
|
|
1331
1608
|
selected = null;
|
|
1609
|
+
focusNodeId = null;
|
|
1610
|
+
pathStartId = null;
|
|
1611
|
+
pathNodeIds = new Set();
|
|
1612
|
+
pathEdgeKeys = new Set();
|
|
1613
|
+
hiddenNodeIds = new Set();
|
|
1332
1614
|
loadGraph().catch(error => {
|
|
1333
1615
|
detail.innerHTML = `<div class="type-badge" style="background:${nodeColor('ClearEvent')}; color:#091019">${t('error')}</div><div class="detail-title">${t('graph_refresh_fail')}</div><div class="detail-summary">${escapeHtml(error.message)}</div>`;
|
|
1334
1616
|
});
|