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.
Files changed (40) hide show
  1. package/README.md +40 -19
  2. package/docs/CHANGELOG.md +107 -0
  3. package/docs/EDITION_STRATEGY.md +14 -4
  4. package/docs/ENTERPRISE.md +11 -3
  5. package/docs/MULTI_AGENT_RUNTIME.md +410 -0
  6. package/docs/PLUGIN_SDK.md +651 -0
  7. package/docs/REALTIME_COLLABORATION.md +410 -0
  8. package/docs/V2_ARCHITECTURE.md +528 -0
  9. package/docs/WORKFLOW_DESIGNER.md +475 -0
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/agents.py +98 -0
  12. package/latticeai/api/plugins.py +115 -0
  13. package/latticeai/api/realtime.py +91 -0
  14. package/latticeai/api/workflow_designer.py +207 -0
  15. package/latticeai/core/multi_agent.py +270 -0
  16. package/latticeai/core/plugins.py +400 -0
  17. package/latticeai/core/realtime.py +190 -0
  18. package/latticeai/core/workflow_engine.py +329 -0
  19. package/latticeai/core/workspace_os.py +165 -2
  20. package/latticeai/server_app.py +76 -2
  21. package/latticeai/services/platform_runtime.py +200 -0
  22. package/package.json +17 -2
  23. package/plugins/README.md +35 -0
  24. package/plugins/git-insights/plugin.json +15 -0
  25. package/plugins/hello-world/plugin.json +16 -0
  26. package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
  27. package/static/activity.html +70 -0
  28. package/static/admin.html +62 -0
  29. package/static/agents.html +92 -0
  30. package/static/graph.html +7 -1
  31. package/static/lattice-reference.css +184 -0
  32. package/static/platform.css +75 -0
  33. package/static/plugins.html +82 -0
  34. package/static/scripts/admin.js +121 -1
  35. package/static/scripts/graph.js +296 -14
  36. package/static/scripts/platform.js +64 -0
  37. package/static/scripts/workspace.js +107 -10
  38. package/static/workflows.html +121 -0
  39. package/static/workspace.css +73 -0
  40. package/static/workspace.html +18 -2
@@ -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 => {
@@ -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: '새로고침', error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
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', error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
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
- document.getElementById('refresh-btn').textContent = `↺ ${t('refresh')}`;
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
- graph.nodes = rawGraph.nodes.filter(node => !hiddenTypes.has(node.type));
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
- showDetail(selected && rawGraph.nodes.find(node => node.id === selected.id) || graph.nodes[0] || null);
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 baseAlpha = neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34;
845
- const widthBoost = isNeighborEdge ? 0.5 : 0;
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
- ${jumpHtml}
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
  });