ltcai 1.6.0 → 2.0.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 +40 -19
- package/docs/CHANGELOG.md +107 -0
- package/docs/EDITION_STRATEGY.md +14 -4
- package/docs/ENTERPRISE.md +11 -3
- package/docs/MULTI_AGENT_RUNTIME.md +410 -0
- package/docs/PLUGIN_SDK.md +651 -0
- package/docs/REALTIME_COLLABORATION.md +410 -0
- package/docs/V2_ARCHITECTURE.md +528 -0
- package/docs/WORKFLOW_DESIGNER.md +475 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +98 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +207 -0
- package/latticeai/core/multi_agent.py +270 -0
- package/latticeai/core/plugins.py +400 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +165 -2
- package/latticeai/server_app.py +76 -2
- package/latticeai/services/platform_runtime.py +200 -0
- package/package.json +17 -2
- package/plugins/README.md +35 -0
- package/plugins/git-insights/plugin.json +15 -0
- package/plugins/hello-world/plugin.json +16 -0
- package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
- package/static/activity.html +70 -0
- package/static/admin.html +62 -0
- package/static/agents.html +92 -0
- package/static/graph.html +7 -1
- package/static/lattice-reference.css +184 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +82 -0
- package/static/scripts/admin.js +121 -1
- package/static/scripts/graph.js +296 -14
- package/static/scripts/platform.js +64 -0
- package/static/scripts/workspace.js +107 -10
- package/static/workflows.html +121 -0
- package/static/workspace.css +73 -0
- package/static/workspace.html +18 -2
package/static/scripts/admin.js
CHANGED
|
@@ -49,6 +49,7 @@ const A18N = {
|
|
|
49
49
|
nav_users: '사용자 관리',
|
|
50
50
|
nav_permissions: '권한 관리',
|
|
51
51
|
nav_sso: 'SSO 관리',
|
|
52
|
+
nav_enterprise: 'Enterprise',
|
|
52
53
|
nav_security: '보안 모니터링',
|
|
53
54
|
nav_audit: '감사 로그',
|
|
54
55
|
nav_chat: '채팅으로',
|
|
@@ -170,6 +171,16 @@ const A18N = {
|
|
|
170
171
|
section_sensitivity: '보안 모니터링',
|
|
171
172
|
section_audit: '감사 로그',
|
|
172
173
|
section_sso: 'SSO 관리',
|
|
174
|
+
enterprise_title: 'Enterprise 관리자',
|
|
175
|
+
enterprise_desc: '관리자 정책, 감사 추출, SIEM 추출, 조직 설정, 기능 상태를 확인합니다.',
|
|
176
|
+
enterprise_policies: '관리자 정책',
|
|
177
|
+
enterprise_policies_desc: 'Community 유효 정책과 Enterprise 정책 팩 상태입니다.',
|
|
178
|
+
enterprise_org: '조직 설정',
|
|
179
|
+
enterprise_org_desc: '워크스페이스 거버넌스와 조직 기능 상태입니다.',
|
|
180
|
+
enterprise_audit_export: '감사 추출',
|
|
181
|
+
enterprise_audit_export_desc: 'Community에서는 로컬 추출이 가능하며 보존 정책은 Enterprise 확장 지점입니다.',
|
|
182
|
+
enterprise_siem: 'SIEM 추출',
|
|
183
|
+
enterprise_siem_desc: 'Community에서 외부 이벤트를 전송하지 않고 SIEM envelope를 미리 봅니다.',
|
|
173
184
|
},
|
|
174
185
|
en: {
|
|
175
186
|
admin_sub: 'Admin Dashboard',
|
|
@@ -180,6 +191,7 @@ const A18N = {
|
|
|
180
191
|
nav_users: 'User Management',
|
|
181
192
|
nav_permissions: 'Permission Management',
|
|
182
193
|
nav_sso: 'SSO Management',
|
|
194
|
+
nav_enterprise: 'Enterprise',
|
|
183
195
|
nav_security: 'Security Monitoring',
|
|
184
196
|
nav_audit: 'Audit Logs',
|
|
185
197
|
nav_chat: 'Back to Chat',
|
|
@@ -301,6 +313,16 @@ const A18N = {
|
|
|
301
313
|
section_sensitivity: 'Security monitoring',
|
|
302
314
|
section_audit: 'Audit logs',
|
|
303
315
|
section_sso: 'SSO management',
|
|
316
|
+
enterprise_title: 'Enterprise Admin',
|
|
317
|
+
enterprise_desc: 'Review admin policies, audit export, SIEM export, organization settings, and capability status.',
|
|
318
|
+
enterprise_policies: 'Admin Policies',
|
|
319
|
+
enterprise_policies_desc: 'Effective Community policy and Enterprise policy-pack status.',
|
|
320
|
+
enterprise_org: 'Organization Settings',
|
|
321
|
+
enterprise_org_desc: 'Workspace governance and organization capability status.',
|
|
322
|
+
enterprise_audit_export: 'Audit Export',
|
|
323
|
+
enterprise_audit_export_desc: 'Community local export is available; retention is an Enterprise extension point.',
|
|
324
|
+
enterprise_siem: 'SIEM Export',
|
|
325
|
+
enterprise_siem_desc: 'Preview the SIEM envelope without streaming external events in Community.',
|
|
304
326
|
}
|
|
305
327
|
};
|
|
306
328
|
|
|
@@ -310,6 +332,7 @@ let latestUsers = [];
|
|
|
310
332
|
let latestSso = null;
|
|
311
333
|
let latestSensitivity = null;
|
|
312
334
|
let latestAudit = null;
|
|
335
|
+
let latestEnterprise = null;
|
|
313
336
|
|
|
314
337
|
function t(key) {
|
|
315
338
|
return (A18N[currentLang] || A18N.ko)[key] || key;
|
|
@@ -352,6 +375,7 @@ function setLang(lang) {
|
|
|
352
375
|
renderSso(latestSso);
|
|
353
376
|
renderSensitivity(latestSensitivity);
|
|
354
377
|
renderAudit(latestAudit);
|
|
378
|
+
renderEnterpriseAdmin(latestEnterprise);
|
|
355
379
|
loadDashboard();
|
|
356
380
|
}
|
|
357
381
|
|
|
@@ -788,6 +812,97 @@ function renderAudit(audit) {
|
|
|
788
812
|
` : `<div class="preview" style="padding:14px">${t('audit_no_events')}</div>`;
|
|
789
813
|
}
|
|
790
814
|
|
|
815
|
+
function enterpriseStatusTag(label, enabled) {
|
|
816
|
+
return `<span class="tag ${enabled ? 'low' : 'medium'}">${esc(label)}: ${enabled ? 'enabled' : 'disabled'}</span>`;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function renderKeyValues(targetId, rows) {
|
|
820
|
+
const target = document.getElementById(targetId);
|
|
821
|
+
if (!target) return;
|
|
822
|
+
target.innerHTML = `
|
|
823
|
+
<div class="enterprise-kv">
|
|
824
|
+
${rows.map(([label, value]) => `
|
|
825
|
+
<div>
|
|
826
|
+
<span>${esc(label)}</span>
|
|
827
|
+
<strong>${esc(value)}</strong>
|
|
828
|
+
</div>
|
|
829
|
+
`).join('')}
|
|
830
|
+
</div>
|
|
831
|
+
`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function renderEnterpriseAdmin(payload) {
|
|
835
|
+
latestEnterprise = payload || null;
|
|
836
|
+
const enterprise = payload || {};
|
|
837
|
+
const edition = enterprise.edition || {};
|
|
838
|
+
const caps = edition.capabilities || {};
|
|
839
|
+
const tags = document.getElementById('enterprise-status-tags');
|
|
840
|
+
if (tags) {
|
|
841
|
+
tags.innerHTML = [
|
|
842
|
+
enterpriseStatusTag('edition', Boolean(edition.is_enterprise)),
|
|
843
|
+
enterpriseStatusTag('policy packs', Boolean(enterprise.admin_policies?.enabled)),
|
|
844
|
+
enterpriseStatusTag('siem', Boolean(enterprise.siem_export?.enabled)),
|
|
845
|
+
].join('');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const grid = document.getElementById('enterprise-capability-status');
|
|
849
|
+
if (grid) {
|
|
850
|
+
const entries = Object.keys(caps).length ? Object.entries(caps) : [];
|
|
851
|
+
grid.innerHTML = entries.length ? entries.map(([name, enabled]) => `
|
|
852
|
+
<div class="enterprise-cap-card ${enabled ? 'on' : 'off'}">
|
|
853
|
+
<i class="ti ${enabled ? 'ti-circle-check' : 'ti-lock'}"></i>
|
|
854
|
+
<span>${esc(name.replaceAll('_', ' '))}</span>
|
|
855
|
+
<strong>${enabled ? 'enabled' : 'disabled'}</strong>
|
|
856
|
+
</div>
|
|
857
|
+
`).join('') : `<div class="preview" style="padding:14px">Capability status unavailable.</div>`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const policies = enterprise.admin_policies || {};
|
|
861
|
+
renderKeyValues('enterprise-admin-policies', [
|
|
862
|
+
['Capability', policies.capability || 'admin_policy_packs'],
|
|
863
|
+
['Enabled', Boolean(policies.enabled)],
|
|
864
|
+
['Enforced', Boolean(policies.enforced)],
|
|
865
|
+
['Base roles', (policies.effective_policy?.base_roles || []).join(', ')],
|
|
866
|
+
['Local file access', policies.effective_policy?.local_file_access || 'approval-token gated'],
|
|
867
|
+
['Package install', policies.effective_policy?.package_install || 'admin-only'],
|
|
868
|
+
['Note', policies.note || 'Community features remain available.'],
|
|
869
|
+
]);
|
|
870
|
+
|
|
871
|
+
const org = enterprise.organization_settings || {};
|
|
872
|
+
renderKeyValues('enterprise-org-settings', [
|
|
873
|
+
['Workspaces', (org.community_baseline?.workspaces || []).join(', ')],
|
|
874
|
+
['Roles', (org.community_baseline?.roles || []).join(', ')],
|
|
875
|
+
['Data isolation', org.community_baseline?.data_isolation || 'single-tenant local storage'],
|
|
876
|
+
['Governance enabled', Object.values(org.governance_capabilities || {}).filter(Boolean).length],
|
|
877
|
+
['Note', org.note || 'Enterprise governance is an extension point.'],
|
|
878
|
+
]);
|
|
879
|
+
|
|
880
|
+
const audit = enterprise.audit_export || {};
|
|
881
|
+
renderKeyValues('enterprise-audit-export', [
|
|
882
|
+
['Local export', audit.local_export?.available ? 'available' : 'unavailable'],
|
|
883
|
+
['Endpoint', audit.local_export?.endpoint || '/admin/security/export'],
|
|
884
|
+
['Formats', (audit.local_export?.formats || []).join(', ')],
|
|
885
|
+
['SIEM streaming', audit.siem_streaming?.enabled ? 'enabled' : 'disabled'],
|
|
886
|
+
['Retention', audit.compliance_retention?.enabled ? 'enabled' : 'disabled'],
|
|
887
|
+
]);
|
|
888
|
+
|
|
889
|
+
const siem = enterprise.siem_export || {};
|
|
890
|
+
renderKeyValues('enterprise-siem-export', [
|
|
891
|
+
['Capability', siem.capability || 'siem_export'],
|
|
892
|
+
['Enabled', Boolean(siem.enabled)],
|
|
893
|
+
['Streamed', Boolean(siem.streamed)],
|
|
894
|
+
['Destination', siem.destination || 'not configured'],
|
|
895
|
+
]);
|
|
896
|
+
const preview = document.getElementById('enterprise-siem-preview');
|
|
897
|
+
if (preview) preview.textContent = JSON.stringify(siem.preview_envelope || {}, null, 2);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function refreshSiemPreview() {
|
|
901
|
+
const res = await apiFetch('/admin/enterprise/siem-export', { headers: adminHeaders() });
|
|
902
|
+
const data = res.ok ? await res.json() : {};
|
|
903
|
+
renderEnterpriseAdmin({ ...(latestEnterprise || {}), siem_export: data });
|
|
904
|
+
}
|
|
905
|
+
|
|
791
906
|
function cellValue(value) {
|
|
792
907
|
if (value === null || value === undefined) return '';
|
|
793
908
|
if (Array.isArray(value)) return value.map(cellValue).filter(Boolean).join('; ');
|
|
@@ -1074,7 +1189,7 @@ async function loadDashboard() {
|
|
|
1074
1189
|
access.style.display = 'none';
|
|
1075
1190
|
|
|
1076
1191
|
try {
|
|
1077
|
-
const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes, ssoRes] = await Promise.all([
|
|
1192
|
+
const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes, ssoRes, enterpriseRes] = await Promise.all([
|
|
1078
1193
|
apiFetch('/health'),
|
|
1079
1194
|
apiFetch('/vpc/status'),
|
|
1080
1195
|
apiFetch('/admin/summary', { headers: adminHeaders() }),
|
|
@@ -1084,6 +1199,7 @@ async function loadDashboard() {
|
|
|
1084
1199
|
apiFetch('/admin/stats', { headers: adminHeaders() }),
|
|
1085
1200
|
apiFetch('/admin/audit', { headers: adminHeaders() }),
|
|
1086
1201
|
apiFetch('/admin/sso', { headers: adminHeaders() }),
|
|
1202
|
+
apiFetch('/admin/enterprise', { headers: adminHeaders() }),
|
|
1087
1203
|
]);
|
|
1088
1204
|
|
|
1089
1205
|
const health = healthRes.ok ? await healthRes.json() : null;
|
|
@@ -1095,6 +1211,7 @@ async function loadDashboard() {
|
|
|
1095
1211
|
const stats = statsRes.ok ? await statsRes.json() : null;
|
|
1096
1212
|
const audit = auditRes.ok ? await auditRes.json() : null;
|
|
1097
1213
|
const sso = ssoRes.ok ? await ssoRes.json() : null;
|
|
1214
|
+
const enterprise = enterpriseRes.ok ? await enterpriseRes.json() : null;
|
|
1098
1215
|
|
|
1099
1216
|
renderSummary(health, summary, vpc);
|
|
1100
1217
|
fillVpcForm(vpc);
|
|
@@ -1103,6 +1220,7 @@ async function loadDashboard() {
|
|
|
1103
1220
|
renderSensitivity(sensitivity);
|
|
1104
1221
|
renderAudit(audit);
|
|
1105
1222
|
renderSso(sso);
|
|
1223
|
+
renderEnterpriseAdmin(enterprise);
|
|
1106
1224
|
|
|
1107
1225
|
if (invite) {
|
|
1108
1226
|
document.getElementById('invite-link-input').value = invite.invite_url;
|
|
@@ -1118,6 +1236,7 @@ async function loadDashboard() {
|
|
|
1118
1236
|
if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
|
|
1119
1237
|
if (!auditRes.ok) failedSections.push(t('section_audit'));
|
|
1120
1238
|
if (!ssoRes.ok) failedSections.push(t('section_sso'));
|
|
1239
|
+
if (!enterpriseRes.ok) failedSections.push('Enterprise');
|
|
1121
1240
|
|
|
1122
1241
|
if (failedSections.length) {
|
|
1123
1242
|
access.style.display = 'block';
|
|
@@ -1189,6 +1308,7 @@ document.getElementById('save-sso-btn').addEventListener('click', saveSso);
|
|
|
1189
1308
|
document.getElementById('test-sso-btn').addEventListener('click', () => {
|
|
1190
1309
|
window.location.href = `${API_BASE}/auth/sso/login`;
|
|
1191
1310
|
});
|
|
1311
|
+
document.getElementById('refresh-siem-btn')?.addEventListener('click', () => refreshSiemPreview().catch(e => alert(String(e))));
|
|
1192
1312
|
document.getElementById('security-export-toggle')?.addEventListener('click', () => toggleExportOptions('security'));
|
|
1193
1313
|
document.getElementById('audit-export-toggle')?.addEventListener('click', () => toggleExportOptions('audit'));
|
|
1194
1314
|
document.querySelectorAll('[data-export-scope][data-export-format]').forEach(btn => {
|
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
|
});
|