memorix 1.0.8 → 1.0.9

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.
@@ -147,6 +147,28 @@ const i18n = {
147
147
  recentGitMemories: 'Recent Git Memories',
148
148
  commit: 'Commit',
149
149
  created: 'Created',
150
+ knowledgeTitle: 'Knowledge Base',
151
+ knowledgeSubtitle: 'Project-scoped engineering knowledge distilled from durable memory',
152
+ knowledgeObservationsUsed: 'Observations Used',
153
+ knowledgeMiniSkillsUsed: 'Mini-skills Used',
154
+ knowledgeRefs: 'Refs',
155
+ knowledgeGenerated: 'Generated',
156
+ knowledgeQuickJump: 'Sections',
157
+ knowledgeNoItems: 'No entries in this section yet',
158
+ knowledgeNoItemsDesc: 'This section will fill in as durable project knowledge is stored.',
159
+ knowledgeUnavailable: 'Knowledge Base Unavailable',
160
+ knowledgeUnavailableDesc: 'Could not load /api/knowledge for the selected project.',
161
+ knowledgeEmpty: 'No Knowledge Base Entries',
162
+ knowledgeEmptyDesc: 'Store durable observations or promote mini-skills to populate this read-only project wiki.',
163
+ knowledgeProvenance: 'Provenance',
164
+ knowledgeEntry: 'entry',
165
+ knowledgeEntries: 'entries',
166
+ knowledgeSectionProjectOverview: 'Project Overview',
167
+ knowledgeSectionCoreDecisions: 'Core Decisions',
168
+ knowledgeSectionOperationalKnowledge: 'Operational Knowledge',
169
+ knowledgeSectionKnownGotchas: 'Known Gotchas',
170
+ knowledgeSectionGitBackedFacts: 'Git-backed Facts',
171
+ knowledgeSectionPromotedSkills: 'Promoted Skills',
150
172
 
151
173
  // Config
152
174
  configTitle: 'Config Provenance',
@@ -314,6 +336,34 @@ const i18n = {
314
336
  graphTopToBottom: 'Top → Bottom',
315
337
  graphMore: 'more',
316
338
 
339
+ // Knowledge Graph (semantic)
340
+ kgTitle: 'Knowledge Graph',
341
+ kgSubtitle: 'Semantic knowledge topology — section clusters, evidence links, provenance',
342
+ kgNodes: 'nodes',
343
+ kgEdges: 'edges',
344
+ kgClusters: 'clusters',
345
+ kgNoData: 'No Knowledge Graph Data',
346
+ kgNoDataDesc: 'Store durable observations or promote mini-skills to generate a semantic knowledge graph.',
347
+ kgClusterFilter: 'Section Cluster',
348
+ kgEdgeTypeFilter: 'Edge Type',
349
+ kgEdgeSupports: 'supports',
350
+ kgEdgeRelatesTo: 'relates to',
351
+ kgEdgeMentions: 'mentions',
352
+ kgEdgeDerivedFrom: 'derived from',
353
+ kgInspectorSummary: 'Summary',
354
+ kgInspectorProvenance: 'Provenance',
355
+ kgInspectorSection: 'Section',
356
+ kgInspectorEntity: 'Entity',
357
+ kgInspectorEvidence: 'Evidence',
358
+ kgInspectorRelatedEdges: 'Related Edges',
359
+ kgInspectorNoEdges: 'No edges',
360
+ kgDataMode: 'Data Source',
361
+ kgModeSemantic: 'Semantic KG',
362
+ kgModeEntity: 'Entity Graph',
363
+ kgViewMode: 'View Mode',
364
+ kgFocused: 'Focused',
365
+ kgFullGraph: 'Full Graph',
366
+
317
367
  // Identity (additional)
318
368
  identityCurrentProject: 'Current Project',
319
369
  identityHistoricalProjects: 'Historical Projects',
@@ -333,6 +383,7 @@ const i18n = {
333
383
  // Nav tooltips + labels
334
384
  navDashboard: 'Dashboard',
335
385
  navGitMemory: 'Git Memory',
386
+ navKnowledge: 'Knowledge Base',
336
387
  navGraph: 'Knowledge Graph',
337
388
  navObservations: 'Observations',
338
389
  navRetention: 'Retention',
@@ -342,6 +393,7 @@ const i18n = {
342
393
  navTeam: 'Agent Team',
343
394
  navLabelDashboard: 'Overview',
344
395
  navLabelGitMemory: 'Git Memory',
396
+ navLabelKnowledge: 'Knowledge',
345
397
  navLabelGraph: 'Graph',
346
398
  navLabelObservations: 'Observations',
347
399
  navLabelRetention: 'Retention',
@@ -506,17 +558,39 @@ const i18n = {
506
558
 
507
559
  // Git Memory
508
560
  gitMemoryTitle: 'Git 记忆',
509
- gitMemorySubtitle: '来自 git 提交的记忆 — 不可变的事实源',
561
+ gitMemorySubtitle: '来自 git 提交的记忆 — 真实来源且不可变',
510
562
  totalGitMemories: 'Git 记忆总数',
511
- uniqueCommits: '唯一提交数',
563
+ uniqueCommits: '唯一提交',
512
564
  typeCoverage: '类型覆盖',
513
565
  noGitMemory: '暂无 Git 记忆',
514
566
  noGitMemoryDesc: '使用以下命令安装 post-commit hook: memorix git-hook-install',
515
567
  noGitMemoriesYet: '暂无 Git 记忆',
516
568
  noGitMemoriesHint: '安装 post-commit hook 以自动捕获 git 记忆:',
517
- recentGitMemories: '最近的 Git 记忆',
569
+ recentGitMemories: '最近 Git 记忆',
518
570
  commit: '提交',
519
571
  created: '创建时间',
572
+ knowledgeTitle: '知识库',
573
+ knowledgeSubtitle: '从持久项目记忆中提炼出的工程知识',
574
+ knowledgeObservationsUsed: '使用的观察',
575
+ knowledgeMiniSkillsUsed: '使用的 Mini-skills',
576
+ knowledgeRefs: '引用',
577
+ knowledgeGenerated: '生成于',
578
+ knowledgeQuickJump: '章节',
579
+ knowledgeNoItems: '本章节暂无条目',
580
+ knowledgeNoItemsDesc: '当项目中出现持久知识后,本章节会自动填充。',
581
+ knowledgeUnavailable: '知识库不可用',
582
+ knowledgeUnavailableDesc: '无法为所选项目加载 /api/knowledge。',
583
+ knowledgeEmpty: '暂无知识库条目',
584
+ knowledgeEmptyDesc: '存储持久观察或提升 mini-skill 后,此只读项目 wiki 会自动填充。',
585
+ knowledgeProvenance: '来源',
586
+ knowledgeEntry: '条',
587
+ knowledgeEntries: '条',
588
+ knowledgeSectionProjectOverview: '项目概览',
589
+ knowledgeSectionCoreDecisions: '核心决策',
590
+ knowledgeSectionOperationalKnowledge: '操作知识',
591
+ knowledgeSectionKnownGotchas: '已知陷阱',
592
+ knowledgeSectionGitBackedFacts: 'Git 事实',
593
+ knowledgeSectionPromotedSkills: '提升技能',
520
594
 
521
595
  // Config
522
596
  configTitle: '配置溯源',
@@ -689,6 +763,34 @@ const i18n = {
689
763
  graphTopToBottom: '从上到下',
690
764
  graphMore: '更多',
691
765
 
766
+ // Knowledge Graph (semantic)
767
+ kgTitle: '知识图谱',
768
+ kgSubtitle: '语义知识拓扑 — 分区聚类、证据关联、溯源',
769
+ kgNodes: '个节点',
770
+ kgEdges: '条边',
771
+ kgClusters: '个聚类',
772
+ kgNoData: '暂无知识图谱数据',
773
+ kgNoDataDesc: '存储持久观察或提升迷你技能以生成语义知识图谱。',
774
+ kgClusterFilter: '分区聚类',
775
+ kgEdgeTypeFilter: '边类型',
776
+ kgEdgeSupports: '支撑',
777
+ kgEdgeRelatesTo: '关联',
778
+ kgEdgeMentions: '提及',
779
+ kgEdgeDerivedFrom: '派生自',
780
+ kgInspectorSummary: '摘要',
781
+ kgInspectorProvenance: '溯源',
782
+ kgInspectorSection: '分区',
783
+ kgInspectorEntity: '实体',
784
+ kgInspectorEvidence: '证据',
785
+ kgInspectorRelatedEdges: '相关边',
786
+ kgInspectorNoEdges: '无边',
787
+ kgDataMode: '数据源',
788
+ kgModeSemantic: '语义图谱',
789
+ kgModeEntity: '实体图谱',
790
+ kgViewMode: '视图模式',
791
+ kgFocused: '聚焦',
792
+ kgFullGraph: '全量',
793
+
692
794
  // Identity (additional)
693
795
  identityCurrentProject: '当前项目',
694
796
  identityHistoricalProjects: '历史项目',
@@ -708,6 +810,7 @@ const i18n = {
708
810
  // Nav tooltips
709
811
  navDashboard: '仪表盘',
710
812
  navGitMemory: 'Git 记忆',
813
+ navKnowledge: '知识库',
711
814
  navGraph: '知识图谱',
712
815
  navObservations: '观察记录',
713
816
  navRetention: '记忆衰减',
@@ -717,6 +820,7 @@ const i18n = {
717
820
  navTeam: 'Agent 团队',
718
821
  navLabelDashboard: '概览',
719
822
  navLabelGitMemory: 'Git 记忆',
823
+ navLabelKnowledge: '知识库',
720
824
  navLabelGraph: '图谱',
721
825
  navLabelObservations: '观察',
722
826
  navLabelRetention: '衰减',
@@ -764,6 +868,14 @@ function t(key) {
764
868
  return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key;
765
869
  }
766
870
 
871
+ function onDomReady(fn) {
872
+ if (document.readyState === 'loading') {
873
+ document.addEventListener('DOMContentLoaded', fn, { once: true });
874
+ } else {
875
+ fn();
876
+ }
877
+ }
878
+
767
879
  function setLang(lang) {
768
880
  currentLang = lang;
769
881
  localStorage.setItem('memorix-lang', lang);
@@ -774,8 +886,8 @@ function setLang(lang) {
774
886
  if (label) label.textContent = lang === 'en' ? '中文' : 'EN';
775
887
 
776
888
  // Update nav tooltips + labels
777
- const tooltipMap = { dashboard: 'navDashboard', 'git-memory': 'navGitMemory', graph: 'navGraph', observations: 'navObservations', retention: 'navRetention', config: 'navConfig', identity: 'navIdentity', sessions: 'navSessions', team: 'navTeam' };
778
- const labelMap = { dashboard: 'navLabelDashboard', 'git-memory': 'navLabelGitMemory', graph: 'navLabelGraph', observations: 'navLabelObservations', retention: 'navLabelRetention', config: 'navLabelConfig', identity: 'navLabelIdentity', sessions: 'navLabelSessions', team: 'navLabelTeam' };
889
+ const tooltipMap = { dashboard: 'navDashboard', 'git-memory': 'navGitMemory', knowledge: 'navKnowledge', graph: 'navGraph', observations: 'navObservations', retention: 'navRetention', config: 'navConfig', identity: 'navIdentity', sessions: 'navSessions', team: 'navTeam' };
890
+ const labelMap = { dashboard: 'navLabelDashboard', 'git-memory': 'navLabelGitMemory', knowledge: 'navLabelKnowledge', graph: 'navLabelGraph', observations: 'navLabelObservations', retention: 'navLabelRetention', config: 'navLabelConfig', identity: 'navLabelIdentity', sessions: 'navLabelSessions', team: 'navLabelTeam' };
779
891
  document.querySelectorAll('.nav-btn').forEach(b => {
780
892
  const page = b.dataset.page;
781
893
  if (page && tooltipMap[page]) b.title = t(tooltipMap[page]);
@@ -810,7 +922,7 @@ function setLang(lang) {
810
922
  }
811
923
 
812
924
  // Init lang toggle button
813
- document.addEventListener('DOMContentLoaded', () => {
925
+ onDomReady(() => {
814
926
  const btn = document.getElementById('lang-toggle');
815
927
  const label = document.getElementById('lang-label');
816
928
  if (label) label.textContent = currentLang === 'en' ? '中文' : 'EN';
@@ -853,7 +965,7 @@ function applyTheme(theme) {
853
965
  // Apply saved theme immediately
854
966
  applyTheme(currentTheme);
855
967
 
856
- document.addEventListener('DOMContentLoaded', () => {
968
+ onDomReady(() => {
857
969
  const themeBtn = document.getElementById('theme-toggle');
858
970
  if (themeBtn) {
859
971
  themeBtn.addEventListener('click', () => {
@@ -866,7 +978,7 @@ document.addEventListener('DOMContentLoaded', () => {
866
978
  // Router & Navigation
867
979
  // ============================================================
868
980
 
869
- const pages = ['dashboard', 'git-memory', 'graph', 'observations', 'retention', 'config', 'identity', 'sessions', 'team'];
981
+ const pages = ['dashboard', 'git-memory', 'knowledge', 'graph', 'observations', 'retention', 'config', 'identity', 'sessions', 'team'];
870
982
  let currentPage = 'dashboard';
871
983
 
872
984
  function navigate(page) {
@@ -1151,7 +1263,7 @@ async function initProjectSwitcher() {
1151
1263
  }
1152
1264
  }
1153
1265
 
1154
- document.addEventListener('DOMContentLoaded', () => {
1266
+ onDomReady(() => {
1155
1267
  initProjectSwitcher();
1156
1268
  });
1157
1269
 
@@ -1167,6 +1279,7 @@ async function loadPage(page) {
1167
1279
  switch (page) {
1168
1280
  case 'dashboard': await loadDashboard(); break;
1169
1281
  case 'git-memory': await loadGitMemory(); break;
1282
+ case 'knowledge': await loadKnowledge(); break;
1170
1283
  case 'graph': await loadGraph(); break;
1171
1284
  case 'observations': await loadObservations(); break;
1172
1285
  case 'retention': await loadRetention(); break;
@@ -1417,6 +1530,201 @@ function renderPieChart(canvasId, entries, icons) {
1417
1530
  ctx.fillText('total', cx, cy + 10);
1418
1531
  }
1419
1532
 
1533
+ async function loadKnowledge() {
1534
+ const container = document.getElementById('page-knowledge');
1535
+ container.innerHTML = '<div class="loading knowledge-loading"><div class="spinner"></div><div class="knowledge-loading-text">' + t('loading') + '</div></div>';
1536
+
1537
+ const kb = await api('knowledge');
1538
+ if (!kb) {
1539
+ container.innerHTML = `
1540
+ <div class="page-header">
1541
+ <h1 class="page-title">${t('knowledgeTitle')}</h1>
1542
+ <p class="page-subtitle">${t('knowledgeSubtitle')}</p>
1543
+ </div>
1544
+ <div class="panel knowledge-error-panel">
1545
+ <div class="panel-body">
1546
+ <div class="knowledge-state-icon"><span class="iconify" data-icon="lucide:book-x"></span></div>
1547
+ <div class="empty-state-title">${t('knowledgeUnavailable')}</div>
1548
+ <div class="empty-state-desc">${t('knowledgeUnavailableDesc')}</div>
1549
+ <button class="team-refresh-btn" id="knowledge-retry-btn" style="margin:16px auto 0;">${t('teamRefresh')}</button>
1550
+ </div>
1551
+ </div>
1552
+ `;
1553
+ const retry = document.getElementById('knowledge-retry-btn');
1554
+ if (retry) retry.addEventListener('click', () => {
1555
+ delete loaded.knowledge;
1556
+ loadPage('knowledge');
1557
+ });
1558
+ return;
1559
+ }
1560
+
1561
+ const sections = normalizeKnowledgeSections(kb.sections || []);
1562
+ const totalItems = sections.reduce((sum, section) => sum + (section.items || []).length, 0);
1563
+ const stats = kb.stats || { observationsUsed: 0, miniSkillsUsed: 0, refs: 0 };
1564
+ const overviewSection = sections.find(s => s.id === 'project-overview');
1565
+ const mainSections = sections.filter(s => s.id !== 'project-overview');
1566
+
1567
+ container.innerHTML = `
1568
+ <div class="knowledge-page-shell">
1569
+ <div class="knowledge-topbar">
1570
+ <div class="page-header knowledge-header">
1571
+ <div class="knowledge-title-block">
1572
+ <h1 class="page-title">${escapeHtml(t('knowledgeTitle'))}</h1>
1573
+ <p class="page-subtitle">${t('knowledgeSubtitle')}</p>
1574
+ <div class="knowledge-project-line" title="${escapeHtml(kb.projectId || '')}">${escapeHtml(kb.projectId || '')}</div>
1575
+ </div>
1576
+ <div class="knowledge-generated">
1577
+ <span>${t('knowledgeGenerated')}</span>
1578
+ <strong>${escapeHtml(formatTime(kb.generatedAt))}</strong>
1579
+ </div>
1580
+ </div>
1581
+
1582
+ <div class="knowledge-stats-grid">
1583
+ <div class="stat-card" data-accent="purple">
1584
+ <div class="stat-label">${t('knowledgeObservationsUsed')}</div>
1585
+ <div class="stat-value">${stats.observationsUsed || 0}</div>
1586
+ </div>
1587
+ <div class="stat-card" data-accent="cyan">
1588
+ <div class="stat-label">${t('knowledgeMiniSkillsUsed')}</div>
1589
+ <div class="stat-value">${stats.miniSkillsUsed || 0}</div>
1590
+ </div>
1591
+ <div class="stat-card" data-accent="green">
1592
+ <div class="stat-label">${t('knowledgeRefs')}</div>
1593
+ <div class="stat-value">${stats.refs || 0}</div>
1594
+ </div>
1595
+ </div>
1596
+
1597
+ <nav class="knowledge-jump" aria-label="${t('knowledgeQuickJump')}">
1598
+ <span class="knowledge-jump-label">${t('knowledgeQuickJump')}</span>
1599
+ ${sections.map(section => `<a href="#${knowledgeAnchor(section.id)}" class="knowledge-jump-chip">${escapeHtml(section.title)} <span>${(section.items || []).length}</span></a>`).join('')}
1600
+ </nav>
1601
+
1602
+ ${overviewSection ? renderKnowledgeOverviewCard(overviewSection) : ''}
1603
+ </div>
1604
+
1605
+ ${totalItems === 0 ? `
1606
+ <div class="panel knowledge-empty-overview">
1607
+ <div class="panel-body">
1608
+ <div class="knowledge-state-icon"><span class="iconify" data-icon="lucide:book-open"></span></div>
1609
+ <div class="empty-state-title">${t('knowledgeEmpty')}</div>
1610
+ <div class="empty-state-desc">${t('knowledgeEmptyDesc')}</div>
1611
+ </div>
1612
+ </div>
1613
+ ` : `
1614
+ <div class="knowledge-sections-region">
1615
+ ${mainSections.map(renderKnowledgeSection).join('')}
1616
+ </div>
1617
+ `}
1618
+ </div>
1619
+ `;
1620
+ }
1621
+
1622
+ function normalizeKnowledgeSections(sections) {
1623
+ const byId = new Map(sections.map(section => [section.id, section]));
1624
+ const order = [
1625
+ ['project-overview', 'knowledgeSectionProjectOverview'],
1626
+ ['core-decisions', 'knowledgeSectionCoreDecisions'],
1627
+ ['operational-knowledge', 'knowledgeSectionOperationalKnowledge'],
1628
+ ['known-gotchas', 'knowledgeSectionKnownGotchas'],
1629
+ ['git-backed-facts', 'knowledgeSectionGitBackedFacts'],
1630
+ ['promoted-skills', 'knowledgeSectionPromotedSkills'],
1631
+ ];
1632
+ return order.map(([id, titleKey]) => {
1633
+ const section = byId.get(id);
1634
+ const localizedTitle = t(titleKey);
1635
+ if (section) return { ...section, title: localizedTitle };
1636
+ return { id, title: localizedTitle, items: [], empty: true };
1637
+ });
1638
+ }
1639
+
1640
+ function knowledgeEntryLabel(count) {
1641
+ return count === 1 ? t('knowledgeEntry') : t('knowledgeEntries');
1642
+ }
1643
+
1644
+ function renderKnowledgeOverviewCard(section) {
1645
+ const item = (section.items || [])[0];
1646
+ if (!item) {
1647
+ return `
1648
+ <section class="knowledge-overview-card knowledge-overview-card--empty" id="${knowledgeAnchor(section.id)}">
1649
+ <div class="knowledge-overview-meta">
1650
+ <span class="knowledge-overview-label">${escapeHtml(section.title)}</span>
1651
+ <span class="knowledge-section-meta">0 ${t('knowledgeEntries')}</span>
1652
+ </div>
1653
+ <div class="knowledge-overview-body">
1654
+ <p class="knowledge-summary">${t('knowledgeNoItemsDesc')}</p>
1655
+ </div>
1656
+ </section>
1657
+ `;
1658
+ }
1659
+ return `
1660
+ <section class="knowledge-overview-card" id="${knowledgeAnchor(section.id)}">
1661
+ <div class="knowledge-overview-meta">
1662
+ <span class="knowledge-overview-label">${escapeHtml(section.title)}</span>
1663
+ <span class="knowledge-overview-title" title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || t('untitled'))}</span>
1664
+ </div>
1665
+ <div class="knowledge-overview-body">
1666
+ <p class="knowledge-summary knowledge-summary--overview">${escapeHtml(item.summary || '')}</p>
1667
+ <div class="knowledge-ref-list">${renderKnowledgeRefs(item.refs || [])}</div>
1668
+ </div>
1669
+ </section>
1670
+ `;
1671
+ }
1672
+
1673
+ function renderKnowledgeSection(section) {
1674
+ const items = section.items || [];
1675
+ return `
1676
+ <section class="panel knowledge-section" id="${knowledgeAnchor(section.id)}">
1677
+ <div class="panel-header knowledge-section-header">
1678
+ <span class="panel-title">${escapeHtml(section.title)}</span>
1679
+ <span class="knowledge-section-meta">${items.length} ${knowledgeEntryLabel(items.length)}</span>
1680
+ </div>
1681
+ <div class="panel-body knowledge-section-body">
1682
+ ${items.length === 0 ? renderKnowledgeEmptySection() : items.map(renderKnowledgeItem).join('')}
1683
+ </div>
1684
+ </section>
1685
+ `;
1686
+ }
1687
+
1688
+ function renderKnowledgeItem(item) {
1689
+ const refs = item.refs || [];
1690
+ return `
1691
+ <article class="knowledge-item">
1692
+ <div class="knowledge-item-head">
1693
+ <h3 title="${escapeHtml(item.title || t('untitled'))}">${escapeHtml(item.title || t('untitled'))}</h3>
1694
+ <span class="type-badge" data-type="${escapeHtml(item.type || 'unknown')}">${escapeHtml(item.type || t('unknown'))}</span>
1695
+ </div>
1696
+ ${item.entityName ? `<div class="knowledge-entity" title="${escapeHtml(item.entityName)}">${escapeHtml(item.entityName)}</div>` : ''}
1697
+ <p class="knowledge-summary">${escapeHtml(item.summary || '')}</p>
1698
+ ${refs.length > 0 ? `<div class="knowledge-ref-list">${renderKnowledgeRefs(refs)}</div>` : ''}
1699
+ </article>
1700
+ `;
1701
+ }
1702
+
1703
+ function renderKnowledgeRefs(refs) {
1704
+ if (refs.length === 0) return '<span class="knowledge-ref-empty">—</span>';
1705
+ return refs.map(ref => {
1706
+ const kind = ref.kind || 'observation';
1707
+ const title = ref.title ? ` title="${escapeHtml(ref.title)}"` : '';
1708
+ return `<span class="knowledge-ref-chip" data-kind="${escapeHtml(kind)}"${title}>${escapeHtml(ref.id || kind)}</span>`;
1709
+ }).join('');
1710
+ }
1711
+
1712
+ function renderKnowledgeEmptySection() {
1713
+ return `
1714
+ <div class="knowledge-section-empty">
1715
+ <span class="iconify" data-icon="lucide:circle-dashed"></span>
1716
+ <div>
1717
+ <strong>${t('knowledgeNoItems')}</strong>
1718
+ <span>${t('knowledgeNoItemsDesc')}</span>
1719
+ </div>
1720
+ </div>
1721
+ `;
1722
+ }
1723
+
1724
+ function knowledgeAnchor(id) {
1725
+ return `knowledge-${String(id || 'section').replace(/[^a-z0-9_-]/gi, '-')}`;
1726
+ }
1727
+
1420
1728
  // ============================================================
1421
1729
  // Memory Topology Explorer — Cytoscape.js + Dagre
1422
1730
  // Focused topology default, not full graph dump
@@ -1428,9 +1736,21 @@ async function loadGraph() {
1428
1736
  const container = document.getElementById('page-graph');
1429
1737
  container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
1430
1738
 
1739
+ // Try semantic Knowledge Graph first
1740
+ try {
1741
+ const kg = await api('knowledge-graph');
1742
+ if (kg && kg.nodes && kg.nodes.length > 0) {
1743
+ renderSemanticGraph(kg);
1744
+ return;
1745
+ }
1746
+ } catch (_e) {
1747
+ // Fallback to entity graph
1748
+ }
1749
+
1750
+ // Fallback: entity/relation graph
1431
1751
  const graph = await api('graph');
1432
1752
  if (!graph || (graph.entities.length === 0 && graph.relations.length === 0)) {
1433
- container.innerHTML = emptyState('<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>', t('noGraphData'), t('noGraphDataDesc'));
1753
+ container.innerHTML = emptyState('<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>', t('kgNoData'), t('kgNoDataDesc'));
1434
1754
  return;
1435
1755
  }
1436
1756
 
@@ -1482,6 +1802,610 @@ async function loadGraph() {
1482
1802
  renderGraph(graph);
1483
1803
  }
1484
1804
 
1805
+ // ============================================================
1806
+ // Semantic Knowledge Graph Renderer — Apache ECharts 5
1807
+ // Force layout, section categories, evidence-based edges
1808
+ // ============================================================
1809
+
1810
+ function renderSemanticGraph(kg) {
1811
+ const container = document.getElementById('page-graph');
1812
+
1813
+ // Section color palette (vivid for both light/dark themes)
1814
+ const sectionPalette = {
1815
+ 'core-decisions': '#7B9FD9',
1816
+ 'operational-knowledge': '#7CC598',
1817
+ 'known-gotchas': '#E08585',
1818
+ 'git-backed-facts': '#56D6A6',
1819
+ 'promoted-skills': '#C9A8FF',
1820
+ };
1821
+ const defaultSectionColor = '#A893C2';
1822
+
1823
+ function getSectionColor(sectionId) {
1824
+ return sectionPalette[sectionId] || defaultSectionColor;
1825
+ }
1826
+
1827
+ // Edge type styles — solid hex + opacity controlled by G6 strokeOpacity
1828
+ const edgeStyleMap = {
1829
+ 'supports': { color: '#69F0AE', arrow: true, dash: false, label: t('kgEdgeSupports') },
1830
+ 'relates_to': { color: '#80D8FF', arrow: false, dash: [4, 4], label: t('kgEdgeRelatesTo') },
1831
+ 'mentions': { color: '#D0BCFF', arrow: true, dash: false, label: t('kgEdgeMentions') },
1832
+ 'derived_from': { color: '#FFB74D', arrow: true, dash: [6, 3], label: t('kgEdgeDerivedFrom') },
1833
+ };
1834
+
1835
+ // Section label i18n map (matches knowledgeSection* keys)
1836
+ const sectionI18nKeys = {
1837
+ 'core-decisions': 'knowledgeSectionCoreDecisions',
1838
+ 'operational-knowledge': 'knowledgeSectionOperationalKnowledge',
1839
+ 'known-gotchas': 'knowledgeSectionKnownGotchas',
1840
+ 'git-backed-facts': 'knowledgeSectionGitBackedFacts',
1841
+ 'promoted-skills': 'knowledgeSectionPromotedSkills',
1842
+ };
1843
+ function sectionLabel(sectionId) {
1844
+ const key = sectionI18nKeys[sectionId];
1845
+ return key ? t(key) : sectionId;
1846
+ }
1847
+
1848
+ // State
1849
+ let activeSections = new Set((kg.clusters || []).map(c => c.sectionId));
1850
+ let activeEdgeTypes = new Set(Object.keys(edgeStyleMap).filter(type => type !== 'relates_to'));
1851
+ let selectedNodeId = null;
1852
+ let echartsInstance = null;
1853
+ let echartsResizeObserver = null;
1854
+ let focusedMode = true; // default: show top-N nodes only
1855
+ const FOCUSED_TOP_N = 40; // max nodes in focused view
1856
+ const MAX_EDGES_PER_NODE_FOCUSED = 4; // cap per-node edges (top-K by priority) to keep graph readable
1857
+ const FOCUSED_EDGE_BUDGET = 120; // hard visual budget for first-render readability
1858
+
1859
+ function isLight() { return document.documentElement.getAttribute('data-theme') === 'light'; }
1860
+
1861
+ // Compute degree (edge count) for each node
1862
+ const nodeDegreeMap = new Map();
1863
+ for (const edge of kg.edges) {
1864
+ nodeDegreeMap.set(edge.source, (nodeDegreeMap.get(edge.source) || 0) + 1);
1865
+ nodeDegreeMap.set(edge.target, (nodeDegreeMap.get(edge.target) || 0) + 1);
1866
+ }
1867
+
1868
+ // Section ordering for stable category index
1869
+ const SECTION_ORDER = ['core-decisions', 'operational-knowledge', 'known-gotchas', 'git-backed-facts', 'promoted-skills'];
1870
+
1871
+ // Build ECharts graph data: { categories, nodes, links }
1872
+ function buildEChartsData() {
1873
+ // Determine which nodes to show
1874
+ let visibleNodes = kg.nodes.filter(n => activeSections.has(n.sectionId));
1875
+ if (focusedMode && visibleNodes.length > FOCUSED_TOP_N) {
1876
+ visibleNodes.sort((a, b) => {
1877
+ const scoreA = (a.evidenceCount || 0) + (nodeDegreeMap.get(a.id) || 0) * 2;
1878
+ const scoreB = (b.evidenceCount || 0) + (nodeDegreeMap.get(b.id) || 0) * 2;
1879
+ return scoreB - scoreA;
1880
+ });
1881
+ visibleNodes = visibleNodes.slice(0, FOCUSED_TOP_N);
1882
+ }
1883
+ const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
1884
+
1885
+ // Categories (one per section, ECharts uses index reference from nodes)
1886
+ const categories = SECTION_ORDER.map(sectionId => ({
1887
+ name: sectionLabel(sectionId),
1888
+ sectionId,
1889
+ itemStyle: { color: getSectionColor(sectionId) },
1890
+ }));
1891
+ const categoryIndexBySection = Object.fromEntries(SECTION_ORDER.map((s, i) => [s, i]));
1892
+ const visibleBySection = new Map(SECTION_ORDER.map(sectionId => [sectionId, []]));
1893
+ visibleNodes.forEach(node => {
1894
+ const group = visibleBySection.get(node.sectionId) || [];
1895
+ group.push(node);
1896
+ visibleBySection.set(node.sectionId, group);
1897
+ });
1898
+
1899
+ const sectionCenters = {
1900
+ 'core-decisions': { x: -360, y: -170 },
1901
+ 'operational-knowledge': { x: 0, y: 0 },
1902
+ 'known-gotchas': { x: -340, y: 190 },
1903
+ 'git-backed-facts': { x: 360, y: -150 },
1904
+ 'promoted-skills': { x: 350, y: 190 },
1905
+ };
1906
+
1907
+ function stableNodePosition(node, index) {
1908
+ const group = visibleBySection.get(node.sectionId) || [];
1909
+ const total = Math.max(1, group.length);
1910
+ const center = sectionCenters[node.sectionId] || { x: 0, y: 0 };
1911
+ const ring = Math.floor(index / 10);
1912
+ const ringIndex = index % 10;
1913
+ const radius = total === 1 ? 0 : 34 + ring * 42;
1914
+ const angleStep = (Math.PI * 2) / Math.min(10, total);
1915
+ const angleOffset = SECTION_ORDER.indexOf(node.sectionId) * 0.55;
1916
+ const angle = ringIndex * angleStep + angleOffset;
1917
+ return {
1918
+ x: Math.round(center.x + Math.cos(angle) * radius),
1919
+ y: Math.round(center.y + Math.sin(angle) * radius),
1920
+ };
1921
+ }
1922
+
1923
+ // Nodes (stable coordinates; dragging a node must not trigger global force rotation)
1924
+ const nodes = visibleNodes.map(node => {
1925
+ const symbolSize = Math.max(14, Math.min(10 + Math.sqrt(node.evidenceCount || 1) * 4, 32));
1926
+ const sectionIndex = (visibleBySection.get(node.sectionId) || []).findIndex(n => n.id === node.id);
1927
+ const pos = stableNodePosition(node, Math.max(0, sectionIndex));
1928
+ return {
1929
+ id: node.id,
1930
+ name: node.label.length > 28 ? node.label.slice(0, 26) + '\u2026' : node.label,
1931
+ x: pos.x,
1932
+ y: pos.y,
1933
+ category: categoryIndexBySection[node.sectionId] ?? 0,
1934
+ symbolSize,
1935
+ value: node.evidenceCount || 0,
1936
+ sectionId: node.sectionId,
1937
+ nodeType: node.nodeType,
1938
+ entityName: node.entityName || '',
1939
+ evidenceCount: node.evidenceCount || 0,
1940
+ summary: node.summary || '',
1941
+ refs: node.refs || [],
1942
+ fullLabel: node.label,
1943
+ };
1944
+ });
1945
+
1946
+ // Collect candidate edges (both endpoints visible + active type)
1947
+ const edgePriority = { supports: 3, derived_from: 2, mentions: 1, relates_to: 0 };
1948
+ const candidateEdges = kg.edges.filter(e =>
1949
+ activeEdgeTypes.has(e.edgeType) &&
1950
+ visibleNodeIds.has(e.source) &&
1951
+ visibleNodeIds.has(e.target),
1952
+ );
1953
+
1954
+ // In focused mode, cap each node's edges to top-K strongest by type priority
1955
+ let finalEdges = candidateEdges;
1956
+ if (focusedMode && candidateEdges.length > 0) {
1957
+ // Sort all candidates by priority descending (stronger types first)
1958
+ const sorted = [...candidateEdges].sort(
1959
+ (a, b) => (edgePriority[b.edgeType] ?? 0) - (edgePriority[a.edgeType] ?? 0),
1960
+ );
1961
+ const perNodeCount = new Map();
1962
+ finalEdges = [];
1963
+ for (const e of sorted) {
1964
+ if (finalEdges.length >= FOCUSED_EDGE_BUDGET) break;
1965
+ const sCount = perNodeCount.get(e.source) || 0;
1966
+ const tCount = perNodeCount.get(e.target) || 0;
1967
+ if (sCount >= MAX_EDGES_PER_NODE_FOCUSED || tCount >= MAX_EDGES_PER_NODE_FOCUSED) continue;
1968
+ perNodeCount.set(e.source, sCount + 1);
1969
+ perNodeCount.set(e.target, tCount + 1);
1970
+ finalEdges.push(e);
1971
+ }
1972
+ }
1973
+
1974
+ // Build ECharts link objects
1975
+ const links = finalEdges.map(edge => {
1976
+ const style = edgeStyleMap[edge.edgeType] || {};
1977
+ return {
1978
+ id: edge.id,
1979
+ source: edge.source,
1980
+ target: edge.target,
1981
+ edgeType: edge.edgeType,
1982
+ edgeLabel: style.label || edge.edgeType,
1983
+ lineStyle: {
1984
+ color: style.color || '#999',
1985
+ opacity: 0.5,
1986
+ width: 1.1,
1987
+ curveness: 0.12,
1988
+ type: Array.isArray(style.dash) ? 'dashed' : 'solid',
1989
+ },
1990
+ symbol: style.arrow ? ['none', 'arrow'] : ['none', 'none'],
1991
+ symbolSize: [4, 7],
1992
+ };
1993
+ });
1994
+
1995
+ return { categories, nodes, links };
1996
+ }
1997
+
1998
+ let lastRenderedCounts = { nodes: 0, edges: 0 };
1999
+
2000
+ // Render the page shell
2001
+ container.innerHTML = `
2002
+ <div class="kg-page-shell">
2003
+ <div class="page-header">
2004
+ <h1 class="page-title">${t('kgTitle')}</h1>
2005
+ <p class="page-subtitle">${t('kgSubtitle')}</p>
2006
+ </div>
2007
+ <div class="graph-layout">
2008
+ <div class="graph-filter-panel" id="kg-filter-panel"></div>
2009
+ <div id="graph-container">
2010
+ <div id="echarts-mount" style="width:100%;height:100%;"></div>
2011
+ <div class="graph-status-bar">
2012
+ <span class="graph-status-item" id="gs-nodes"></span>
2013
+ <span class="graph-status-item" id="gs-edges"></span>
2014
+ <span class="graph-status-item" id="gs-clusters"></span>
2015
+ <div class="graph-zoom-controls">
2016
+ <button class="graph-zoom-btn" id="gz-out">\u2212</button>
2017
+ <button class="graph-zoom-btn" id="gz-fit">\u2B21</button>
2018
+ <button class="graph-zoom-btn" id="gz-in">+</button>
2019
+ </div>
2020
+ </div>
2021
+ </div>
2022
+ <div class="graph-inspector" id="graph-inspector">
2023
+ <div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>${t('graphSelectNode')}</div>
2024
+ </div>
2025
+ </div>
2026
+ </div>
2027
+ `;
2028
+
2029
+ function initEChartsGraph() {
2030
+ const mountEl = document.getElementById('echarts-mount');
2031
+ if (!mountEl || typeof echarts === 'undefined') return;
2032
+
2033
+ // Ensure container has dimensions before init
2034
+ if (mountEl.clientWidth === 0 || mountEl.clientHeight === 0) {
2035
+ // Defer until layout finishes
2036
+ requestAnimationFrame(() => initEChartsGraph());
2037
+ return;
2038
+ }
2039
+
2040
+ // Dispose previous instance if any
2041
+ if (echartsInstance) {
2042
+ try { echartsInstance.dispose(); } catch (_) { /* noop */ }
2043
+ echartsInstance = null;
2044
+ }
2045
+
2046
+ const data = buildEChartsData();
2047
+ lastRenderedCounts = { nodes: data.nodes.length, edges: data.links.length };
2048
+ const light = isLight();
2049
+ const labelColor = light ? '#1C1B1F' : '#E6E1E5';
2050
+ const labelMutedColor = light ? '#666' : '#999';
2051
+ const tooltipBg = light ? 'rgba(255,255,255,0.96)' : 'rgba(20,20,28,0.96)';
2052
+ const tooltipBorder = light ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.12)';
2053
+
2054
+ echartsInstance = echarts.init(mountEl, null, { renderer: 'canvas' });
2055
+
2056
+ const wrap = (txt) => String(txt ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2057
+
2058
+ const option = {
2059
+ backgroundColor: 'transparent',
2060
+ tooltip: {
2061
+ trigger: 'item',
2062
+ backgroundColor: tooltipBg,
2063
+ borderColor: tooltipBorder,
2064
+ borderWidth: 1,
2065
+ textStyle: { color: labelColor, fontSize: 12, fontFamily: 'Inter, system-ui, sans-serif' },
2066
+ extraCssText: 'box-shadow: 0 4px 16px rgba(0,0,0,0.18); border-radius: 8px; padding: 10px 12px; max-width: 320px;',
2067
+ formatter: (params) => {
2068
+ if (params.dataType === 'node') {
2069
+ const d = params.data;
2070
+ const summary = d.summary ? String(d.summary).slice(0, 160) : '';
2071
+ return (
2072
+ `<div style="font-weight:600;font-size:13px;margin-bottom:4px;line-height:1.35;">${wrap(d.fullLabel || d.name)}</div>` +
2073
+ `<div style="font-size:11px;color:${labelMutedColor};margin-bottom:6px;">${wrap(d.nodeType || '')}${d.entityName ? ' \u00b7 ' + wrap(d.entityName) : ''}</div>` +
2074
+ `<div style="font-size:11px;color:${labelMutedColor};">${d.evidenceCount || 0} ${wrap(t('kgInspectorEvidence'))}</div>` +
2075
+ (summary ? `<div style="font-size:11.5px;line-height:1.5;margin-top:8px;color:${labelColor};opacity:0.85;">${wrap(summary)}\u2026</div>` : '')
2076
+ );
2077
+ }
2078
+ if (params.dataType === 'edge') {
2079
+ return `<div style="font-size:12px;">${wrap(params.data.edgeLabel || params.data.edgeType)}</div>`;
2080
+ }
2081
+ return '';
2082
+ },
2083
+ },
2084
+ legend: [{
2085
+ data: data.categories.map(c => c.name),
2086
+ textStyle: { color: labelColor, fontSize: 11 },
2087
+ top: 8,
2088
+ right: 12,
2089
+ itemWidth: 10,
2090
+ itemHeight: 10,
2091
+ itemGap: 12,
2092
+ icon: 'circle',
2093
+ selectedMode: false,
2094
+ }],
2095
+ animationDuration: 500,
2096
+ animationEasingUpdate: 'quinticInOut',
2097
+ series: [{
2098
+ type: 'graph',
2099
+ layout: 'none',
2100
+ roam: true,
2101
+ draggable: true,
2102
+ zoom: 1,
2103
+ scaleLimit: { min: 0.2, max: 4 },
2104
+ edgeSymbol: ['none', 'arrow'],
2105
+ edgeSymbolSize: [0, 8],
2106
+ categories: data.categories,
2107
+ data: data.nodes,
2108
+ edges: data.links,
2109
+ label: {
2110
+ show: true,
2111
+ position: 'right',
2112
+ formatter: '{b}',
2113
+ color: labelColor,
2114
+ fontSize: 11,
2115
+ fontFamily: 'Inter, system-ui, sans-serif',
2116
+ backgroundColor: light ? 'rgba(255,255,255,0.82)' : 'rgba(15,15,23,0.72)',
2117
+ padding: [2, 5],
2118
+ borderRadius: 3,
2119
+ },
2120
+ labelLayout: { hideOverlap: true, moveOverlap: 'shiftY' },
2121
+ itemStyle: {
2122
+ opacity: 0.92,
2123
+ borderColor: light ? 'rgba(0,0,0,0.22)' : 'rgba(255,255,255,0.2)',
2124
+ borderWidth: 1,
2125
+ shadowColor: light ? 'rgba(0,0,0,0.18)' : 'rgba(0,0,0,0.45)',
2126
+ shadowBlur: 8,
2127
+ },
2128
+ emphasis: {
2129
+ focus: 'adjacency',
2130
+ label: { show: true, fontWeight: 700, fontSize: 12 },
2131
+ itemStyle: { borderColor: light ? '#6750A4' : '#D0BCFF', borderWidth: 2 },
2132
+ lineStyle: { width: 2.5, opacity: 1 },
2133
+ },
2134
+ blur: {
2135
+ itemStyle: { opacity: 0.18 },
2136
+ label: { opacity: 0.25 },
2137
+ lineStyle: { opacity: 0.06 },
2138
+ },
2139
+ lineStyle: { opacity: 0.55, curveness: 0.15, width: 1.2 },
2140
+ }],
2141
+ };
2142
+
2143
+ echartsInstance.setOption(option);
2144
+
2145
+ // Click handlers — use ECharts event API (auto-cleaned on dispose) instead of zrender
2146
+ echartsInstance.on('click', (params) => {
2147
+ if (params.dataType === 'node') {
2148
+ const id = params.data?.id;
2149
+ if (id) {
2150
+ selectedNodeId = id;
2151
+ showKGInspector(id);
2152
+ }
2153
+ }
2154
+ });
2155
+ // Click on blank canvas deselects (ECharts emits click with empty target at chart level).
2156
+ // Use `click` event at series level only; do NOT intercept zrender events so roam/pan works.
2157
+ // User reported pan-on-empty-canvas not working when zr click was intercepted.
2158
+
2159
+ // Resize observer
2160
+ if (echartsResizeObserver) {
2161
+ try { echartsResizeObserver.disconnect(); } catch (_) { /* noop */ }
2162
+ }
2163
+ if (typeof ResizeObserver !== 'undefined') {
2164
+ echartsResizeObserver = new ResizeObserver(() => {
2165
+ if (echartsInstance) echartsInstance.resize();
2166
+ });
2167
+ echartsResizeObserver.observe(mountEl);
2168
+ }
2169
+
2170
+ window._kgEChart = echartsInstance;
2171
+ window._kgShowInspector = showKGInspector;
2172
+
2173
+ updateStatusBar();
2174
+ bindZoomControls();
2175
+ }
2176
+
2177
+ function bindZoomControls() {
2178
+ const outBtn = document.getElementById('gz-out');
2179
+ const fitBtn = document.getElementById('gz-fit');
2180
+ const inBtn = document.getElementById('gz-in');
2181
+ const setZoom = (factor) => {
2182
+ if (!echartsInstance) return;
2183
+ const opt = echartsInstance.getOption();
2184
+ const cur = (opt.series && opt.series[0] && opt.series[0].zoom) || 1;
2185
+ echartsInstance.setOption({ series: [{ zoom: Math.min(4, Math.max(0.2, cur * factor)) }] });
2186
+ };
2187
+ if (outBtn) outBtn.onclick = () => setZoom(0.75);
2188
+ if (fitBtn) fitBtn.onclick = () => {
2189
+ if (echartsInstance) echartsInstance.setOption({ series: [{ zoom: 1, center: null }] });
2190
+ };
2191
+ if (inBtn) inBtn.onclick = () => setZoom(1.35);
2192
+ }
2193
+
2194
+ function updateStatusBar() {
2195
+ const gsNodes = document.getElementById('gs-nodes');
2196
+ const gsEdges = document.getElementById('gs-edges');
2197
+ const gsClusters = document.getElementById('gs-clusters');
2198
+ const shownNodes = focusedMode ? Math.min(kg.nodes.length, FOCUSED_TOP_N) : kg.nodes.length;
2199
+ const shownLabel = focusedMode && kg.nodes.length > FOCUSED_TOP_N ? `${shownNodes}/${kg.nodes.length}` : `${kg.nodes.length}`;
2200
+ if (gsNodes) gsNodes.textContent = `${shownLabel} ${t('kgNodes')}`;
2201
+ const edgeLabel = lastRenderedCounts.edges < kg.edges.length
2202
+ ? `${lastRenderedCounts.edges}/${kg.edges.length}`
2203
+ : `${kg.edges.length}`;
2204
+ if (gsEdges) gsEdges.textContent = `${edgeLabel} ${t('kgEdges')}`;
2205
+ if (gsClusters) gsClusters.textContent = `${(kg.clusters || []).length} ${t('kgClusters')}`;
2206
+ }
2207
+
2208
+ // Inspector
2209
+ function showKGInspector(nodeId) {
2210
+ const inspector = document.getElementById('graph-inspector');
2211
+ if (!inspector) return;
2212
+ if (!nodeId) {
2213
+ inspector.innerHTML = '<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>' + t('graphSelectNode') + '</div>';
2214
+ return;
2215
+ }
2216
+ const node = kg.nodes.find(n => n.id === nodeId);
2217
+ if (!node) {
2218
+ inspector.innerHTML = '<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>' + t('graphSelectNode') + '</div>';
2219
+ return;
2220
+ }
2221
+ const color = getSectionColor(node.sectionId);
2222
+ const relatedEdges = kg.edges.filter(e => e.source === nodeId || e.target === nodeId);
2223
+
2224
+ const refsHtml = (node.refs || []).length > 0
2225
+ ? node.refs.map(r => `<span class="knowledge-ref-chip" data-kind="${escapeHtml(r.kind)}">${escapeHtml(r.id)}</span>`).join('')
2226
+ : '<span style="font-size:12px;color:var(--text-muted);">—</span>';
2227
+
2228
+ const edgeHtml = relatedEdges.length > 0
2229
+ ? relatedEdges.map(e => {
2230
+ const dir = e.source === nodeId;
2231
+ const other = dir ? e.target : e.source;
2232
+ const otherNode = kg.nodes.find(n => n.id === other);
2233
+ const style = edgeStyleMap[e.edgeType] || {};
2234
+ return `<div class="gi-rel-item">
2235
+ <span class="gi-rel-arrow">${dir ? '\u2192' : '\u2190'}</span>
2236
+ <span class="gi-rel-type" style="color:${style.color || 'var(--text-muted)'}">${escapeHtml(style.label || e.edgeType)}</span>
2237
+ <span class="gi-rel-target" data-kg-nav="${escapeHtml(other)}">${escapeHtml(otherNode?.label || other)}</span>
2238
+ </div>`;
2239
+ }).join('')
2240
+ : '<div style="font-size:12px;color:var(--text-muted);font-style:italic;">' + t('kgInspectorNoEdges') + '</div>';
2241
+
2242
+ inspector.innerHTML = `
2243
+ <div class="gi-header">
2244
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
2245
+ <span style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></span>
2246
+ <div class="gi-name">${escapeHtml(node.label)}</div>
2247
+ </div>
2248
+ <div class="gi-type">${escapeHtml(node.nodeType)}</div>
2249
+ </div>
2250
+ <div class="gi-stats">
2251
+ <div class="gi-stat"><div class="gi-stat-value">${node.evidenceCount}</div><div class="gi-stat-label">${t('kgInspectorEvidence')}</div></div>
2252
+ <div class="gi-stat"><div class="gi-stat-value">${relatedEdges.length}</div><div class="gi-stat-label">${t('kgInspectorRelatedEdges')}</div></div>
2253
+ </div>
2254
+ <div class="gi-section">
2255
+ <div class="gi-section-title">${t('kgInspectorSection')}</div>
2256
+ <div style="font-size:12px;color:${color};font-weight:500;">${escapeHtml(sectionLabel(node.sectionId))}</div>
2257
+ </div>
2258
+ ${node.entityName ? `<div class="gi-section"><div class="gi-section-title">${t('kgInspectorEntity')}</div><div style="font-size:12px;color:var(--accent-cyan);font-family:var(--font-mono);">${escapeHtml(node.entityName)}</div></div>` : ''}
2259
+ <div class="gi-section">
2260
+ <div class="gi-section-title">${t('kgInspectorSummary')}</div>
2261
+ <div style="font-size:12px;color:var(--text-secondary);line-height:1.5;">${escapeHtml(node.summary || '—')}</div>
2262
+ </div>
2263
+ <div class="gi-section">
2264
+ <div class="gi-section-title">${t('kgInspectorProvenance')}</div>
2265
+ <div class="knowledge-ref-list">${refsHtml}</div>
2266
+ </div>
2267
+ <div class="gi-section">
2268
+ <div class="gi-section-title">${t('kgInspectorRelatedEdges')} <span class="gi-section-count">${relatedEdges.length}</span></div>
2269
+ ${edgeHtml}
2270
+ </div>
2271
+ `;
2272
+
2273
+ inspector.querySelectorAll('[data-kg-nav]').forEach(el => {
2274
+ el.addEventListener('click', () => {
2275
+ const targetId = el.dataset.kgNav;
2276
+ if (echartsInstance) {
2277
+ try {
2278
+ echartsInstance.dispatchAction({ type: 'highlight', seriesIndex: 0, dataType: 'node', name: kg.nodes.find(n => n.id === targetId)?.label });
2279
+ } catch (_) { /* noop */ }
2280
+ }
2281
+ showKGInspector(targetId);
2282
+ });
2283
+ });
2284
+ }
2285
+
2286
+ // Filter panel
2287
+ function renderKGFilterPanel() {
2288
+ const panel = document.getElementById('kg-filter-panel');
2289
+ if (!panel) return;
2290
+
2291
+ const clusterEntries = (kg.clusters || []).map(c => [c.sectionId, c]);
2292
+ const clusterHtml = `
2293
+ <div class="gfp-section">
2294
+ <div class="gfp-label">${t('kgClusterFilter')}</div>
2295
+ <div class="gfp-radio-group">
2296
+ ${clusterEntries.map(([id, cluster]) => `
2297
+ <button class="gfp-check${activeSections.has(id) ? ' active' : ''}" data-section-filter="${escapeHtml(id)}">
2298
+ <span class="gfp-check-box">\u2713</span>
2299
+ <span class="gfp-type-dot" style="background:${getSectionColor(id)}"></span>
2300
+ ${escapeHtml(sectionLabel(cluster.sectionId))}
2301
+ <span class="gfp-check-count">${cluster.nodeCount}</span>
2302
+ </button>
2303
+ `).join('')}
2304
+ </div>
2305
+ </div>
2306
+ `;
2307
+
2308
+ const edgeTypeEntries = Object.entries(edgeStyleMap);
2309
+ const edgeTypeHtml = `
2310
+ <div class="gfp-section">
2311
+ <div class="gfp-label">${t('kgEdgeTypeFilter')}</div>
2312
+ <div class="gfp-radio-group">
2313
+ ${edgeTypeEntries.map(([type, style]) => `
2314
+ <button class="gfp-check${activeEdgeTypes.has(type) ? ' active' : ''}" data-edge-type-filter="${escapeHtml(type)}">
2315
+ <span class="gfp-check-box">\u2713</span>
2316
+ <span style="color:${style.color};font-size:11px;">\u2192</span>
2317
+ ${escapeHtml(style.label)}
2318
+ <span class="gfp-check-count">${kg.edges.filter(e => e.edgeType === type).length}</span>
2319
+ </button>
2320
+ `).join('')}
2321
+ </div>
2322
+ </div>
2323
+ `;
2324
+
2325
+ const searchHtml = `
2326
+ <div class="gfp-section">
2327
+ <div class="gfp-label">${t('graphSearch')}</div>
2328
+ <input type="text" class="gfp-search" id="kg-search" placeholder="${t('graphFindEntity')}" autocomplete="off" />
2329
+ </div>
2330
+ `;
2331
+
2332
+ const viewModeHtml = `
2333
+ <div class="gfp-section">
2334
+ <div class="gfp-label">${t('kgViewMode')}</div>
2335
+ <div class="gfp-depth-row">
2336
+ <button class="gfp-depth-btn${focusedMode ? ' active' : ''}" data-view-mode="focused">${t('kgFocused')}</button>
2337
+ <button class="gfp-depth-btn${!focusedMode ? ' active' : ''}" data-view-mode="full">${t('kgFullGraph')}</button>
2338
+ </div>
2339
+ </div>
2340
+ `;
2341
+
2342
+ panel.innerHTML = searchHtml + viewModeHtml + clusterHtml + edgeTypeHtml;
2343
+
2344
+ // Bind section filters
2345
+ panel.querySelectorAll('[data-section-filter]').forEach(btn => {
2346
+ btn.addEventListener('click', () => {
2347
+ const id = btn.dataset.sectionFilter;
2348
+ if (activeSections.has(id)) activeSections.delete(id);
2349
+ else activeSections.add(id);
2350
+ initEChartsGraph();
2351
+ renderKGFilterPanel();
2352
+ });
2353
+ });
2354
+
2355
+ // Bind edge type filters
2356
+ panel.querySelectorAll('[data-edge-type-filter]').forEach(btn => {
2357
+ btn.addEventListener('click', () => {
2358
+ const type = btn.dataset.edgeTypeFilter;
2359
+ if (activeEdgeTypes.has(type)) activeEdgeTypes.delete(type);
2360
+ else activeEdgeTypes.add(type);
2361
+ initEChartsGraph();
2362
+ renderKGFilterPanel();
2363
+ });
2364
+ });
2365
+
2366
+ // Bind view mode toggle
2367
+ panel.querySelectorAll('[data-view-mode]').forEach(btn => {
2368
+ btn.addEventListener('click', () => {
2369
+ const mode = btn.dataset.viewMode;
2370
+ focusedMode = mode === 'focused';
2371
+ initEChartsGraph();
2372
+ renderKGFilterPanel();
2373
+ });
2374
+ });
2375
+
2376
+ // Bind search
2377
+ const searchInput = document.getElementById('kg-search');
2378
+ if (searchInput) {
2379
+ searchInput.addEventListener('input', () => {
2380
+ const q = searchInput.value.toLowerCase();
2381
+ if (!echartsInstance) return;
2382
+ try {
2383
+ echartsInstance.dispatchAction({ type: 'downplay', seriesIndex: 0 });
2384
+ } catch (_) { /* noop */ }
2385
+ if (!q) return;
2386
+ // Highlight matched nodes (which auto-blurs others via emphasis.focus 'adjacency' isn't ideal here;
2387
+ // instead, use 'highlight' on matching dataIndex array for visual emphasis)
2388
+ const data = buildEChartsData();
2389
+ const matchIndexes = [];
2390
+ data.nodes.forEach((n, idx) => {
2391
+ const match = (n.fullLabel || '').toLowerCase().includes(q) ||
2392
+ (n.nodeType || '').toLowerCase().includes(q) ||
2393
+ (n.entityName || '').toLowerCase().includes(q);
2394
+ if (match) matchIndexes.push(idx);
2395
+ });
2396
+ if (matchIndexes.length > 0) {
2397
+ try {
2398
+ echartsInstance.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: matchIndexes });
2399
+ } catch (_) { /* noop */ }
2400
+ }
2401
+ });
2402
+ }
2403
+ }
2404
+
2405
+ initEChartsGraph();
2406
+ renderKGFilterPanel();
2407
+ }
2408
+
1485
2409
  // ============================================================
1486
2410
  // Cytoscape.js + Dagre — Focused Topology Renderer
1487
2411
  // Default: 1-hop neighborhood of top entity, dagre LR layout