memorix 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +22 -6
- package/dist/cli/index.js +691 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +320 -24
- package/dist/dashboard/static/style.css +114 -11
- package/dist/index.js +544 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
|
@@ -31,6 +31,7 @@ const i18n = {
|
|
|
31
31
|
nodes: 'nodes',
|
|
32
32
|
edges: 'edges',
|
|
33
33
|
clickNodeToView: 'Click a node to view details',
|
|
34
|
+
legend: 'Legend',
|
|
34
35
|
noObservations: 'No observations',
|
|
35
36
|
noRelations: 'No relations',
|
|
36
37
|
|
|
@@ -45,12 +46,21 @@ const i18n = {
|
|
|
45
46
|
exportData: 'Export',
|
|
46
47
|
deleteObs: 'Delete',
|
|
47
48
|
deleteConfirm: 'Delete observation #%id%?',
|
|
49
|
+
batchCleanup: 'Cleanup',
|
|
50
|
+
selected: 'selected',
|
|
51
|
+
cancel: 'Cancel',
|
|
52
|
+
deleteSelected: 'Delete Selected',
|
|
53
|
+
batchDeleteConfirm: 'Delete %count% observations?',
|
|
48
54
|
deleted: 'Deleted',
|
|
49
55
|
narrative: 'Narrative',
|
|
50
56
|
facts: 'Facts',
|
|
51
57
|
concepts: 'Concepts',
|
|
52
58
|
files: 'Files Modified',
|
|
53
59
|
clickToExpand: 'Click to expand',
|
|
60
|
+
vectorSearch: 'Vector Search',
|
|
61
|
+
fulltextOnly: 'Fulltext Only',
|
|
62
|
+
enabled: 'Enabled',
|
|
63
|
+
typeDistribution: 'Type Distribution',
|
|
54
64
|
|
|
55
65
|
// Retention
|
|
56
66
|
memoryRetention: 'Memory Retention',
|
|
@@ -100,6 +110,7 @@ const i18n = {
|
|
|
100
110
|
nodes: '个节点',
|
|
101
111
|
edges: '条边',
|
|
102
112
|
clickNodeToView: '点击节点查看详情',
|
|
113
|
+
legend: '图例',
|
|
103
114
|
noObservations: '暂无观察',
|
|
104
115
|
noRelations: '暂无关系',
|
|
105
116
|
|
|
@@ -114,12 +125,21 @@ const i18n = {
|
|
|
114
125
|
exportData: '导出',
|
|
115
126
|
deleteObs: '删除',
|
|
116
127
|
deleteConfirm: '确认删除观察 #%id%?',
|
|
128
|
+
batchCleanup: '清理',
|
|
129
|
+
selected: '已选中',
|
|
130
|
+
cancel: '取消',
|
|
131
|
+
deleteSelected: '删除选中',
|
|
132
|
+
batchDeleteConfirm: '确认删除 %count% 条观察?',
|
|
117
133
|
deleted: '已删除',
|
|
118
134
|
narrative: '叙述',
|
|
119
135
|
facts: '事实',
|
|
120
136
|
concepts: '概念',
|
|
121
137
|
files: '相关文件',
|
|
122
138
|
clickToExpand: '点击展开',
|
|
139
|
+
vectorSearch: '向量搜索',
|
|
140
|
+
fulltextOnly: '仅全文搜索',
|
|
141
|
+
enabled: '已启用',
|
|
142
|
+
typeDistribution: '类型分布',
|
|
123
143
|
|
|
124
144
|
// Retention
|
|
125
145
|
memoryRetention: '记忆衰减',
|
|
@@ -386,6 +406,11 @@ async function loadDashboard() {
|
|
|
386
406
|
<div class="stat-label">${t('nextId')}</div>
|
|
387
407
|
<div class="stat-value">#${stats.nextId}</div>
|
|
388
408
|
</div>
|
|
409
|
+
<div class="stat-card" data-accent="${stats.embedding?.enabled ? 'cyan' : 'amber'}">
|
|
410
|
+
<div class="stat-label">${t('vectorSearch')}</div>
|
|
411
|
+
<div class="stat-value" style="font-size: 18px;">${stats.embedding?.enabled ? '✓ ' + t('enabled') : t('fulltextOnly')}</div>
|
|
412
|
+
${stats.embedding?.provider ? `<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px; font-family: var(--font-mono);">${stats.embedding.provider} (${stats.embedding.dimensions}d)</div>` : ''}
|
|
413
|
+
</div>
|
|
389
414
|
</div>
|
|
390
415
|
|
|
391
416
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
@@ -394,16 +419,23 @@ async function loadDashboard() {
|
|
|
394
419
|
<span class="panel-title">${t('observationTypes')}</span>
|
|
395
420
|
</div>
|
|
396
421
|
<div class="panel-body">
|
|
397
|
-
${typeEntries.length > 0 ?
|
|
398
|
-
<div style="display: flex;
|
|
399
|
-
<
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
422
|
+
${typeEntries.length > 0 ? `
|
|
423
|
+
<div style="display: flex; gap: 20px; align-items: flex-start;">
|
|
424
|
+
<canvas id="type-pie-chart" width="140" height="140" style="flex-shrink: 0;"></canvas>
|
|
425
|
+
<div style="flex: 1;">
|
|
426
|
+
${typeEntries.map(([type, count]) => `
|
|
427
|
+
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
428
|
+
<span style="width: 18px; text-align: center; font-size: 13px;">${typeIcons[type] || '❓'}</span>
|
|
429
|
+
<span style="width: 110px; font-size: 11px; color: var(--text-secondary);">${type}</span>
|
|
430
|
+
<div style="flex: 1; height: 5px; background: rgba(128,128,128,0.1); border-radius: 3px; overflow: hidden;">
|
|
431
|
+
<div style="width: ${(count / maxTypeCount) * 100}%; height: 100%; background: var(--type-${type}, var(--accent-cyan)); border-radius: 3px;"></div>
|
|
432
|
+
</div>
|
|
433
|
+
<span style="font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); min-width: 22px; text-align: right;">${count}</span>
|
|
434
|
+
</div>
|
|
435
|
+
`).join('')}
|
|
403
436
|
</div>
|
|
404
|
-
<span style="font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); min-width: 24px; text-align: right;">${count}</span>
|
|
405
437
|
</div>
|
|
406
|
-
`
|
|
438
|
+
` : `<p style="color: var(--text-muted); font-size: 13px;">${t('noObservationsYet')}</p>`}
|
|
407
439
|
</div>
|
|
408
440
|
</div>
|
|
409
441
|
|
|
@@ -430,6 +462,55 @@ async function loadDashboard() {
|
|
|
430
462
|
</div>
|
|
431
463
|
</div>
|
|
432
464
|
`;
|
|
465
|
+
|
|
466
|
+
// Render pie chart if data exists
|
|
467
|
+
if (typeEntries.length > 0) {
|
|
468
|
+
requestAnimationFrame(() => renderPieChart('type-pie-chart', typeEntries, typeIcons));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Draw a mini donut chart on a canvas */
|
|
473
|
+
function renderPieChart(canvasId, entries, icons) {
|
|
474
|
+
const canvas = document.getElementById(canvasId);
|
|
475
|
+
if (!canvas) return;
|
|
476
|
+
const ctx = canvas.getContext('2d');
|
|
477
|
+
const dpr = window.devicePixelRatio || 1;
|
|
478
|
+
const size = 140;
|
|
479
|
+
canvas.width = size * dpr;
|
|
480
|
+
canvas.height = size * dpr;
|
|
481
|
+
canvas.style.width = size + 'px';
|
|
482
|
+
canvas.style.height = size + 'px';
|
|
483
|
+
ctx.scale(dpr, dpr);
|
|
484
|
+
|
|
485
|
+
const cx = size / 2, cy = size / 2, r = 54, inner = 34;
|
|
486
|
+
const total = entries.reduce((s, e) => s + e[1], 0);
|
|
487
|
+
const colors = [
|
|
488
|
+
'#06b6d4', '#a855f7', '#f59e0b', '#22c55e',
|
|
489
|
+
'#3b82f6', '#ef4444', '#ec4899', '#f97316', '#6366f1',
|
|
490
|
+
];
|
|
491
|
+
|
|
492
|
+
let angle = -Math.PI / 2;
|
|
493
|
+
entries.forEach(([type, count], i) => {
|
|
494
|
+
const slice = (count / total) * Math.PI * 2;
|
|
495
|
+
ctx.beginPath();
|
|
496
|
+
ctx.moveTo(cx + inner * Math.cos(angle), cy + inner * Math.sin(angle));
|
|
497
|
+
ctx.arc(cx, cy, r, angle, angle + slice);
|
|
498
|
+
ctx.arc(cx, cy, inner, angle + slice, angle, true);
|
|
499
|
+
ctx.closePath();
|
|
500
|
+
ctx.fillStyle = colors[i % colors.length];
|
|
501
|
+
ctx.fill();
|
|
502
|
+
angle += slice;
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Center text
|
|
506
|
+
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim() || '#fff';
|
|
507
|
+
ctx.font = 'bold 20px system-ui';
|
|
508
|
+
ctx.textAlign = 'center';
|
|
509
|
+
ctx.textBaseline = 'middle';
|
|
510
|
+
ctx.fillText(total, cx, cy - 6);
|
|
511
|
+
ctx.font = '10px system-ui';
|
|
512
|
+
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim() || '#888';
|
|
513
|
+
ctx.fillText('total', cx, cy + 10);
|
|
433
514
|
}
|
|
434
515
|
|
|
435
516
|
// ============================================================
|
|
@@ -632,6 +713,11 @@ function renderGraph(graph) {
|
|
|
632
713
|
for (const node of nodes) {
|
|
633
714
|
const active = node === hoveredNode || node === selectedNode;
|
|
634
715
|
|
|
716
|
+
// Legend hover dimming
|
|
717
|
+
if (node._dimmed) {
|
|
718
|
+
ctx.globalAlpha = 0.15;
|
|
719
|
+
}
|
|
720
|
+
|
|
635
721
|
// Glow + dashed ring
|
|
636
722
|
if (active) {
|
|
637
723
|
const pr = node.radius + 12 + Math.sin(pulsePhase * 3) * 3;
|
|
@@ -686,11 +772,91 @@ function renderGraph(graph) {
|
|
|
686
772
|
ctx.fillText(String(node.observations.length), bx, by);
|
|
687
773
|
ctx.textBaseline = 'alphabetic';
|
|
688
774
|
}
|
|
775
|
+
|
|
776
|
+
// Reset alpha after dimming
|
|
777
|
+
ctx.globalAlpha = 1;
|
|
689
778
|
}
|
|
690
779
|
|
|
691
780
|
if (selectedNode && !animating) requestAnimationFrame(draw);
|
|
692
781
|
}
|
|
693
782
|
|
|
783
|
+
// --- Knowledge Graph Legend ---
|
|
784
|
+
function buildLegend() {
|
|
785
|
+
let existing = container.querySelector('.graph-legend');
|
|
786
|
+
if (existing) existing.remove();
|
|
787
|
+
|
|
788
|
+
const legend = document.createElement('div');
|
|
789
|
+
legend.className = 'graph-legend';
|
|
790
|
+
legend.style.cssText = `
|
|
791
|
+
position: absolute; top: 12px; right: 12px; z-index: 10;
|
|
792
|
+
background: var(--bg-card);
|
|
793
|
+
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
794
|
+
border: 1px solid var(--border-medium);
|
|
795
|
+
border-radius: 12px; padding: 12px 14px; min-width: 140px;
|
|
796
|
+
font-family: 'Inter', sans-serif; font-size: 11px;
|
|
797
|
+
color: var(--text-secondary);
|
|
798
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
|
|
799
|
+
transition: opacity 0.3s;
|
|
800
|
+
`;
|
|
801
|
+
|
|
802
|
+
// Type stats
|
|
803
|
+
const typeCount = {};
|
|
804
|
+
nodes.forEach(n => { typeCount[n.type] = (typeCount[n.type] || 0) + 1; });
|
|
805
|
+
|
|
806
|
+
// Title
|
|
807
|
+
const title = document.createElement('div');
|
|
808
|
+
title.style.cssText = 'font-weight: 600; font-size: 11px; margin-bottom: 8px; color: var(--text-primary); letter-spacing: 0.5px; text-transform: uppercase;';
|
|
809
|
+
title.textContent = t('legend') || 'Legend';
|
|
810
|
+
legend.appendChild(title);
|
|
811
|
+
|
|
812
|
+
// Type entries
|
|
813
|
+
Object.entries(typeCount)
|
|
814
|
+
.sort((a, b) => b[1] - a[1])
|
|
815
|
+
.forEach(([type, count]) => {
|
|
816
|
+
const row = document.createElement('div');
|
|
817
|
+
row.style.cssText = 'display: flex; align-items: center; gap: 8px; padding: 3px 4px; border-radius: 6px; cursor: pointer; transition: background 0.2s;';
|
|
818
|
+
|
|
819
|
+
const dot = document.createElement('span');
|
|
820
|
+
dot.style.cssText = `width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; background: ${typeColors[type] || '#666'}; box-shadow: 0 0 6px ${typeColors[type] || '#666'}44;`;
|
|
821
|
+
|
|
822
|
+
const label = document.createElement('span');
|
|
823
|
+
label.style.cssText = 'flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;';
|
|
824
|
+
label.textContent = type;
|
|
825
|
+
|
|
826
|
+
const badge = document.createElement('span');
|
|
827
|
+
badge.style.cssText = 'font-size: 10px; opacity: 0.6;';
|
|
828
|
+
badge.textContent = count;
|
|
829
|
+
|
|
830
|
+
row.appendChild(dot);
|
|
831
|
+
row.appendChild(label);
|
|
832
|
+
row.appendChild(badge);
|
|
833
|
+
|
|
834
|
+
// Hover to highlight same-type nodes
|
|
835
|
+
row.addEventListener('mouseenter', () => {
|
|
836
|
+
row.style.background = 'var(--bg-card-hover)';
|
|
837
|
+
nodes.forEach(n => { n._dimmed = n.type !== type; });
|
|
838
|
+
draw();
|
|
839
|
+
});
|
|
840
|
+
row.addEventListener('mouseleave', () => {
|
|
841
|
+
row.style.background = '';
|
|
842
|
+
nodes.forEach(n => { n._dimmed = false; });
|
|
843
|
+
draw();
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
legend.appendChild(row);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// Stats footer
|
|
850
|
+
const stats = document.createElement('div');
|
|
851
|
+
stats.style.cssText = 'margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-subtle); font-size: 10px; opacity: 0.5;';
|
|
852
|
+
stats.textContent = `${nodes.length} nodes · ${edges.length} edges`;
|
|
853
|
+
legend.appendChild(stats);
|
|
854
|
+
|
|
855
|
+
container.style.position = 'relative';
|
|
856
|
+
container.appendChild(legend);
|
|
857
|
+
}
|
|
858
|
+
buildLegend();
|
|
859
|
+
|
|
694
860
|
function showDetail(node) {
|
|
695
861
|
const panel = document.getElementById('graph-detail');
|
|
696
862
|
if (!node) {
|
|
@@ -775,6 +941,85 @@ function renderGraph(graph) {
|
|
|
775
941
|
let allObservations = [];
|
|
776
942
|
let obsFilter = '';
|
|
777
943
|
let obsTypeFilter = '';
|
|
944
|
+
let batchMode = false;
|
|
945
|
+
let selectedIds = new Set();
|
|
946
|
+
|
|
947
|
+
// Low quality detection (same patterns as CLI cleanup)
|
|
948
|
+
const LOW_QUALITY_OBS_PATTERNS = [
|
|
949
|
+
/^Session activity/i,
|
|
950
|
+
/^Updated \S+\.\w+$/i,
|
|
951
|
+
/^Created \S+\.\w+$/i,
|
|
952
|
+
/^Deleted \S+\.\w+$/i,
|
|
953
|
+
/^Modified \S+\.\w+$/i,
|
|
954
|
+
/^Ran command:/i,
|
|
955
|
+
/^Read file:/i,
|
|
956
|
+
];
|
|
957
|
+
function isLowQualityObs(title) {
|
|
958
|
+
return LOW_QUALITY_OBS_PATTERNS.some(p => p.test(title.trim()));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function renderBatchToolbar() {
|
|
962
|
+
const slot = document.getElementById('batch-toolbar-slot');
|
|
963
|
+
if (!slot) return;
|
|
964
|
+
if (!batchMode || selectedIds.size === 0) {
|
|
965
|
+
slot.innerHTML = '';
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
slot.innerHTML = `
|
|
969
|
+
<div class="batch-toolbar">
|
|
970
|
+
<span class="batch-count">${selectedIds.size} ${t('selected') || 'selected'}</span>
|
|
971
|
+
<button class="batch-cancel-btn" onclick="exitBatchMode()">${t('cancel') || 'Cancel'}</button>
|
|
972
|
+
<button class="batch-delete-btn" onclick="batchDeleteSelected()">🗑️ ${t('deleteSelected') || 'Delete Selected'}</button>
|
|
973
|
+
</div>
|
|
974
|
+
`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function batchDeleteSelected() {
|
|
978
|
+
if (selectedIds.size === 0) return;
|
|
979
|
+
const msg = (t('batchDeleteConfirm') || 'Delete %count% observations?').replace('%count%', selectedIds.size);
|
|
980
|
+
if (!confirm(msg)) return;
|
|
981
|
+
|
|
982
|
+
const sep = selectedProject ? `?project=${encodeURIComponent(selectedProject)}` : '';
|
|
983
|
+
let deleted = 0;
|
|
984
|
+
for (const id of selectedIds) {
|
|
985
|
+
try {
|
|
986
|
+
const res = await fetch(`/api/observations/${id}${sep}`, { method: 'DELETE' });
|
|
987
|
+
const data = await res.json();
|
|
988
|
+
if (data.ok) deleted++;
|
|
989
|
+
} catch { /* ignore individual failures */ }
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
allObservations = allObservations.filter(o => !selectedIds.has(o.id));
|
|
993
|
+
selectedIds.clear();
|
|
994
|
+
batchMode = false;
|
|
995
|
+
renderObsList();
|
|
996
|
+
renderBatchToolbar();
|
|
997
|
+
|
|
998
|
+
// Update counter
|
|
999
|
+
const subtitle = document.querySelector('#page-observations .page-subtitle');
|
|
1000
|
+
if (subtitle) subtitle.textContent = `${allObservations.length} ${t('observationsStored')}`;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function exitBatchMode() {
|
|
1004
|
+
batchMode = false;
|
|
1005
|
+
selectedIds.clear();
|
|
1006
|
+
renderObsList();
|
|
1007
|
+
renderBatchToolbar();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function toggleObsSelect(id) {
|
|
1011
|
+
if (selectedIds.has(id)) {
|
|
1012
|
+
selectedIds.delete(id);
|
|
1013
|
+
} else {
|
|
1014
|
+
selectedIds.add(id);
|
|
1015
|
+
}
|
|
1016
|
+
renderBatchToolbar();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Make batch functions globally accessible
|
|
1020
|
+
window.exitBatchMode = exitBatchMode;
|
|
1021
|
+
window.batchDeleteSelected = batchDeleteSelected;
|
|
1022
|
+
window.toggleObsSelect = toggleObsSelect;
|
|
778
1023
|
|
|
779
1024
|
async function loadObservations() {
|
|
780
1025
|
const container = document.getElementById('page-observations');
|
|
@@ -797,12 +1042,19 @@ async function loadObservations() {
|
|
|
797
1042
|
<h1 class="page-title">${t('observations')}</h1>
|
|
798
1043
|
<p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
|
|
799
1044
|
</div>
|
|
800
|
-
<
|
|
801
|
-
<
|
|
802
|
-
|
|
803
|
-
|
|
1045
|
+
<div style="display:flex;gap:8px;">
|
|
1046
|
+
<button class="export-btn" id="btn-batch-cleanup" title="${t('batchCleanup') || 'Batch Cleanup'}">
|
|
1047
|
+
🧹 ${t('batchCleanup') || 'Cleanup'}
|
|
1048
|
+
</button>
|
|
1049
|
+
<button class="export-btn" id="btn-export" title="${t('exportData')}">
|
|
1050
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M4 7l4 4 4-4M2 12v2h12v-2"/></svg>
|
|
1051
|
+
${t('exportData')}
|
|
1052
|
+
</button>
|
|
1053
|
+
</div>
|
|
804
1054
|
</div>
|
|
805
1055
|
|
|
1056
|
+
<div id="batch-toolbar-slot"></div>
|
|
1057
|
+
|
|
806
1058
|
<div class="search-bar">
|
|
807
1059
|
<input class="search-input" id="obs-search" type="text" placeholder="${t('searchObservations')}" />
|
|
808
1060
|
<button class="filter-btn active" data-type="" id="filter-all">${t('all')}</button>
|
|
@@ -818,6 +1070,22 @@ async function loadObservations() {
|
|
|
818
1070
|
window.open(`/api/export${sep}`, '_blank');
|
|
819
1071
|
});
|
|
820
1072
|
|
|
1073
|
+
// Batch cleanup: enter batch mode, auto-select low-quality observations
|
|
1074
|
+
document.getElementById('btn-batch-cleanup').addEventListener('click', () => {
|
|
1075
|
+
batchMode = !batchMode;
|
|
1076
|
+
if (batchMode) {
|
|
1077
|
+
// Auto-select low quality ones
|
|
1078
|
+
selectedIds.clear();
|
|
1079
|
+
allObservations.forEach(obs => {
|
|
1080
|
+
if (isLowQualityObs(obs.title || '')) selectedIds.add(obs.id);
|
|
1081
|
+
});
|
|
1082
|
+
} else {
|
|
1083
|
+
selectedIds.clear();
|
|
1084
|
+
}
|
|
1085
|
+
renderObsList();
|
|
1086
|
+
renderBatchToolbar();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
821
1089
|
document.getElementById('obs-search').addEventListener('input', (e) => {
|
|
822
1090
|
obsFilter = e.target.value.toLowerCase();
|
|
823
1091
|
renderObsList();
|
|
@@ -865,26 +1133,32 @@ function renderObsList() {
|
|
|
865
1133
|
return;
|
|
866
1134
|
}
|
|
867
1135
|
|
|
868
|
-
list.innerHTML = filtered.map(obs =>
|
|
869
|
-
|
|
1136
|
+
list.innerHTML = filtered.map(obs => {
|
|
1137
|
+
const isLow = isLowQualityObs(obs.title || '');
|
|
1138
|
+
const isSelected = selectedIds.has(obs.id);
|
|
1139
|
+
const hl = (text) => obsFilter ? escapeHtml(text).replace(new RegExp(`(${escapeHtml(obsFilter).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'), '<mark>$1</mark>') : escapeHtml(text);
|
|
1140
|
+
return `
|
|
1141
|
+
<div class="obs-card${isLow ? ' low-quality' : ''}" data-obs-id="${obs.id}">
|
|
870
1142
|
<div class="obs-card-header" onclick="toggleObsDetail(${obs.id})">
|
|
1143
|
+
${batchMode ? `<input type="checkbox" class="obs-checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleObsSelect(${obs.id}); this.checked = !this.checked;" />` : ''}
|
|
871
1144
|
<span class="obs-card-id">#${obs.id}</span>
|
|
872
1145
|
<span class="type-badge" data-type="${obs.type || 'unknown'}">
|
|
873
1146
|
${typeIcons[obs.type] || '❓'} ${obs.type || 'unknown'}
|
|
874
1147
|
</span>
|
|
875
|
-
<span class="
|
|
1148
|
+
${isLow ? '<span class="low-quality-badge">low quality</span>' : ''}
|
|
1149
|
+
<span class="obs-card-title">${hl(obs.title || t('untitled'))}</span>
|
|
876
1150
|
<span class="obs-expand-icon">▼</span>
|
|
877
1151
|
</div>
|
|
878
1152
|
<div class="obs-card-meta">
|
|
879
|
-
<span>📁 ${
|
|
1153
|
+
<span>📁 ${hl(obs.entityName || 'unknown')}</span>
|
|
880
1154
|
${obs.createdAt ? `<span>🕐 ${formatTime(obs.createdAt)}</span>` : ''}
|
|
881
1155
|
${obs.accessCount ? `<span>👁 ${obs.accessCount}</span>` : ''}
|
|
882
1156
|
</div>
|
|
883
1157
|
<div class="obs-detail" id="obs-detail-${obs.id}" style="display:none;">
|
|
884
|
-
${obs.narrative ? `<div class="obs-detail-section"><label>${t('narrative')}</label><div class="obs-card-narrative">${
|
|
885
|
-
${obs.facts && obs.facts.length > 0 ? `<div class="obs-detail-section"><label>${t('facts')}</label><div class="obs-card-facts">${obs.facts.map(f => `<span class="fact-tag">${
|
|
886
|
-
${obs.concepts && obs.concepts.length > 0 ? `<div class="obs-detail-section"><label>${t('concepts')}</label><div class="obs-card-facts">${obs.concepts.map(c => `<span class="fact-tag concept-tag">${
|
|
887
|
-
${obs.filesModified && obs.filesModified.length > 0 ? `<div class="obs-detail-section"><label>${t('files')}</label><div class="obs-card-facts">${obs.filesModified.map(f => `<span class="fact-tag file-tag">${
|
|
1158
|
+
${obs.narrative ? `<div class="obs-detail-section"><label>${t('narrative')}</label><div class="obs-card-narrative">${hl(obs.narrative)}</div></div>` : ''}
|
|
1159
|
+
${obs.facts && obs.facts.length > 0 ? `<div class="obs-detail-section"><label>${t('facts')}</label><div class="obs-card-facts">${obs.facts.map(f => `<span class="fact-tag">${hl(f)}</span>`).join('')}</div></div>` : ''}
|
|
1160
|
+
${obs.concepts && obs.concepts.length > 0 ? `<div class="obs-detail-section"><label>${t('concepts')}</label><div class="obs-card-facts">${obs.concepts.map(c => `<span class="fact-tag concept-tag">${hl(c)}</span>`).join('')}</div></div>` : ''}
|
|
1161
|
+
${obs.filesModified && obs.filesModified.length > 0 ? `<div class="obs-detail-section"><label>${t('files')}</label><div class="obs-card-facts">${obs.filesModified.map(f => `<span class="fact-tag file-tag">${hl(f)}</span>`).join('')}</div></div>` : ''}
|
|
888
1162
|
<div class="obs-detail-actions">
|
|
889
1163
|
<button class="delete-btn" onclick="deleteObs(${obs.id}, event)">
|
|
890
1164
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v5M10 7v5M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
|
@@ -893,7 +1167,8 @@ function renderObsList() {
|
|
|
893
1167
|
</div>
|
|
894
1168
|
</div>
|
|
895
1169
|
</div>
|
|
896
|
-
|
|
1170
|
+
`;
|
|
1171
|
+
}).join('');
|
|
897
1172
|
}
|
|
898
1173
|
|
|
899
1174
|
// ============================================================
|
|
@@ -991,9 +1266,30 @@ function toggleObsDetail(id) {
|
|
|
991
1266
|
const card = detail?.closest('.obs-card');
|
|
992
1267
|
if (!detail || !card) return;
|
|
993
1268
|
|
|
994
|
-
const isOpen =
|
|
995
|
-
|
|
996
|
-
|
|
1269
|
+
const isOpen = card.classList.contains('expanded');
|
|
1270
|
+
|
|
1271
|
+
if (isOpen) {
|
|
1272
|
+
// Collapse with animation
|
|
1273
|
+
detail.style.maxHeight = detail.scrollHeight + 'px';
|
|
1274
|
+
requestAnimationFrame(() => {
|
|
1275
|
+
detail.style.maxHeight = '0';
|
|
1276
|
+
detail.style.opacity = '0';
|
|
1277
|
+
});
|
|
1278
|
+
setTimeout(() => { detail.style.display = 'none'; }, 300);
|
|
1279
|
+
card.classList.remove('expanded');
|
|
1280
|
+
} else {
|
|
1281
|
+
// Expand with animation
|
|
1282
|
+
detail.style.display = 'block';
|
|
1283
|
+
detail.style.maxHeight = '0';
|
|
1284
|
+
detail.style.opacity = '0';
|
|
1285
|
+
requestAnimationFrame(() => {
|
|
1286
|
+
detail.style.maxHeight = detail.scrollHeight + 'px';
|
|
1287
|
+
detail.style.opacity = '1';
|
|
1288
|
+
});
|
|
1289
|
+
// Remove max-height constraint after animation
|
|
1290
|
+
setTimeout(() => { detail.style.maxHeight = 'none'; }, 300);
|
|
1291
|
+
card.classList.add('expanded');
|
|
1292
|
+
}
|
|
997
1293
|
|
|
998
1294
|
// Rotate expand icon
|
|
999
1295
|
const icon = card.querySelector('.obs-expand-icon');
|
|
@@ -1158,24 +1158,113 @@ body {
|
|
|
1158
1158
|
color: var(--text-secondary);
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
-
/* Observation Detail Section */
|
|
1161
|
+
/* Observation Detail Section — smooth expand/collapse */
|
|
1162
1162
|
.obs-detail {
|
|
1163
1163
|
margin-top: 12px;
|
|
1164
1164
|
padding-top: 12px;
|
|
1165
1165
|
border-top: 1px solid var(--border-subtle);
|
|
1166
|
-
|
|
1166
|
+
overflow: hidden;
|
|
1167
|
+
transition: max-height 0.3s ease, opacity 0.3s ease;
|
|
1167
1168
|
}
|
|
1168
1169
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1170
|
+
/* Batch action toolbar */
|
|
1171
|
+
.batch-toolbar {
|
|
1172
|
+
display: flex;
|
|
1173
|
+
align-items: center;
|
|
1174
|
+
gap: 10px;
|
|
1175
|
+
padding: 10px 16px;
|
|
1176
|
+
background: var(--bg-card);
|
|
1177
|
+
border: 1px solid var(--border-subtle);
|
|
1178
|
+
border-radius: 10px;
|
|
1179
|
+
margin-bottom: 12px;
|
|
1180
|
+
animation: fadeIn 0.2s ease;
|
|
1181
|
+
}
|
|
1174
1182
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1183
|
+
.batch-toolbar .batch-count {
|
|
1184
|
+
font-size: 12px;
|
|
1185
|
+
color: var(--text-secondary);
|
|
1186
|
+
flex: 1;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
.batch-toolbar button {
|
|
1190
|
+
padding: 6px 14px;
|
|
1191
|
+
border-radius: 6px;
|
|
1192
|
+
font-size: 12px;
|
|
1193
|
+
font-family: var(--font-sans);
|
|
1194
|
+
cursor: pointer;
|
|
1195
|
+
transition: all 0.2s;
|
|
1196
|
+
border: 1px solid;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
.batch-toolbar .batch-delete-btn {
|
|
1200
|
+
background: rgba(239, 68, 68, 0.1);
|
|
1201
|
+
border-color: rgba(239, 68, 68, 0.3);
|
|
1202
|
+
color: var(--accent-red);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
.batch-toolbar .batch-delete-btn:hover {
|
|
1206
|
+
background: rgba(239, 68, 68, 0.2);
|
|
1207
|
+
border-color: var(--accent-red);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
.batch-toolbar .batch-cancel-btn {
|
|
1211
|
+
background: transparent;
|
|
1212
|
+
border-color: var(--border-medium);
|
|
1213
|
+
color: var(--text-secondary);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
.batch-toolbar .batch-cancel-btn:hover {
|
|
1217
|
+
border-color: var(--text-secondary);
|
|
1218
|
+
color: var(--text-primary);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/* Low quality indicator */
|
|
1222
|
+
.obs-card.low-quality {
|
|
1223
|
+
border-left: 3px solid var(--accent-amber);
|
|
1224
|
+
opacity: 0.7;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
.obs-card.low-quality:hover {
|
|
1228
|
+
opacity: 1;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.low-quality-badge {
|
|
1232
|
+
font-size: 9px;
|
|
1233
|
+
padding: 1px 6px;
|
|
1234
|
+
border-radius: 4px;
|
|
1235
|
+
background: rgba(245, 158, 11, 0.15);
|
|
1236
|
+
color: var(--accent-amber);
|
|
1237
|
+
font-weight: 600;
|
|
1238
|
+
text-transform: uppercase;
|
|
1239
|
+
letter-spacing: 0.5px;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/* Batch select checkbox */
|
|
1243
|
+
.obs-checkbox {
|
|
1244
|
+
appearance: none;
|
|
1245
|
+
width: 16px;
|
|
1246
|
+
height: 16px;
|
|
1247
|
+
border: 1.5px solid var(--border-medium);
|
|
1248
|
+
border-radius: 4px;
|
|
1249
|
+
background: transparent;
|
|
1250
|
+
cursor: pointer;
|
|
1251
|
+
transition: all 0.2s;
|
|
1252
|
+
flex-shrink: 0;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
.obs-checkbox:checked {
|
|
1256
|
+
background: var(--accent-cyan);
|
|
1257
|
+
border-color: var(--accent-cyan);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
.obs-checkbox:checked::after {
|
|
1261
|
+
content: '✓';
|
|
1262
|
+
display: block;
|
|
1263
|
+
text-align: center;
|
|
1264
|
+
font-size: 11px;
|
|
1265
|
+
color: #000;
|
|
1266
|
+
line-height: 14px;
|
|
1267
|
+
font-weight: bold;
|
|
1179
1268
|
}
|
|
1180
1269
|
|
|
1181
1270
|
.obs-detail-section {
|
|
@@ -1194,6 +1283,7 @@ body {
|
|
|
1194
1283
|
|
|
1195
1284
|
.obs-detail-section .obs-card-narrative {
|
|
1196
1285
|
-webkit-line-clamp: unset;
|
|
1286
|
+
line-clamp: unset;
|
|
1197
1287
|
white-space: pre-wrap;
|
|
1198
1288
|
max-height: none;
|
|
1199
1289
|
}
|
|
@@ -1242,4 +1332,17 @@ body {
|
|
|
1242
1332
|
background: rgba(239, 68, 68, 0.1);
|
|
1243
1333
|
border-color: var(--accent-red);
|
|
1244
1334
|
opacity: 1;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/* Search highlight */
|
|
1338
|
+
mark {
|
|
1339
|
+
background: rgba(245, 158, 11, 0.25);
|
|
1340
|
+
color: inherit;
|
|
1341
|
+
padding: 1px 2px;
|
|
1342
|
+
border-radius: 2px;
|
|
1343
|
+
font-weight: 600;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
[data-theme="light"] mark {
|
|
1347
|
+
background: rgba(245, 158, 11, 0.35);
|
|
1245
1348
|
}
|