wikimem 0.1.2 → 0.1.3

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.
@@ -0,0 +1,872 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>llmwiki — Mission Control</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&family=Instrument+Serif&display=swap" rel="stylesheet">
8
+ <script src="https://d3js.org/d3.v7.min.js"></script>
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+
12
+ :root {
13
+ --bg: #141312;
14
+ --bg-card: #1c1b1a;
15
+ --bg-hover: #242322;
16
+ --border: #2a2928;
17
+ --text: #e8e4df;
18
+ --text-dim: #8a8580;
19
+ --text-muted: #5a5550;
20
+ --purple: #6b21a8;
21
+ --purple-light: #8B5CF6;
22
+ --purple-glow: rgba(139, 92, 246, 0.15);
23
+ --green: #22c55e;
24
+ --amber: #f59e0b;
25
+ --red: #ef4444;
26
+ --radius: 12px;
27
+ }
28
+
29
+ body {
30
+ font-family: 'Poppins', system-ui, sans-serif;
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ line-height: 1.6;
34
+ min-height: 100vh;
35
+ }
36
+
37
+ /* Header */
38
+ .header {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ padding: 20px 32px;
43
+ border-bottom: 1px solid var(--border);
44
+ }
45
+
46
+ .header h1 {
47
+ font-family: 'Instrument Serif', serif;
48
+ font-size: 28px;
49
+ font-weight: 400;
50
+ letter-spacing: -0.5px;
51
+ }
52
+
53
+ .header h1 span { color: var(--purple-light); }
54
+
55
+ .header-status {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 8px;
59
+ font-size: 13px;
60
+ color: var(--text-dim);
61
+ }
62
+
63
+ .status-dot {
64
+ width: 8px;
65
+ height: 8px;
66
+ border-radius: 50%;
67
+ background: var(--green);
68
+ animation: pulse 2s infinite;
69
+ }
70
+
71
+ @keyframes pulse {
72
+ 0%, 100% { opacity: 1; }
73
+ 50% { opacity: 0.5; }
74
+ }
75
+
76
+ /* Nav */
77
+ .nav {
78
+ display: flex;
79
+ gap: 4px;
80
+ padding: 12px 32px;
81
+ border-bottom: 1px solid var(--border);
82
+ }
83
+
84
+ .nav button {
85
+ background: transparent;
86
+ border: none;
87
+ color: var(--text-dim);
88
+ font-family: 'Poppins', sans-serif;
89
+ font-size: 13px;
90
+ font-weight: 500;
91
+ padding: 8px 16px;
92
+ border-radius: 8px;
93
+ cursor: pointer;
94
+ transition: all 0.2s;
95
+ }
96
+
97
+ .nav button:hover { background: var(--bg-hover); color: var(--text); }
98
+ .nav button.active { background: var(--purple-glow); color: var(--purple-light); }
99
+
100
+ /* Main Layout */
101
+ .main { padding: 24px 32px; }
102
+
103
+ /* Cards */
104
+ .card {
105
+ background: var(--bg-card);
106
+ border: 1px solid var(--border);
107
+ border-radius: var(--radius);
108
+ padding: 24px;
109
+ margin-bottom: 20px;
110
+ }
111
+
112
+ .card-title {
113
+ font-family: 'Instrument Serif', serif;
114
+ font-size: 20px;
115
+ margin-bottom: 16px;
116
+ color: var(--text);
117
+ }
118
+
119
+ /* Dashboard Grid */
120
+ .stats-grid {
121
+ display: grid;
122
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
123
+ gap: 16px;
124
+ margin-bottom: 24px;
125
+ }
126
+
127
+ .stat-card {
128
+ background: var(--bg-card);
129
+ border: 1px solid var(--border);
130
+ border-radius: var(--radius);
131
+ padding: 20px;
132
+ text-align: center;
133
+ transition: border-color 0.2s;
134
+ }
135
+
136
+ .stat-card:hover { border-color: var(--purple); }
137
+
138
+ .stat-value {
139
+ font-size: 32px;
140
+ font-weight: 700;
141
+ color: var(--purple-light);
142
+ line-height: 1;
143
+ }
144
+
145
+ .stat-label {
146
+ font-size: 12px;
147
+ color: var(--text-dim);
148
+ margin-top: 6px;
149
+ text-transform: uppercase;
150
+ letter-spacing: 0.5px;
151
+ }
152
+
153
+ /* Pages Table */
154
+ .pages-table {
155
+ width: 100%;
156
+ border-collapse: collapse;
157
+ }
158
+
159
+ .pages-table th {
160
+ text-align: left;
161
+ font-size: 11px;
162
+ font-weight: 600;
163
+ text-transform: uppercase;
164
+ letter-spacing: 0.5px;
165
+ color: var(--text-muted);
166
+ padding: 8px 12px;
167
+ border-bottom: 1px solid var(--border);
168
+ }
169
+
170
+ .pages-table td {
171
+ padding: 10px 12px;
172
+ font-size: 13px;
173
+ border-bottom: 1px solid var(--border);
174
+ }
175
+
176
+ .pages-table tr:hover td { background: var(--bg-hover); }
177
+
178
+ .pages-table .title-cell { color: var(--purple-light); cursor: pointer; }
179
+ .pages-table .title-cell:hover { text-decoration: underline; }
180
+
181
+ .category-badge {
182
+ display: inline-block;
183
+ padding: 2px 8px;
184
+ border-radius: 4px;
185
+ font-size: 11px;
186
+ font-weight: 500;
187
+ background: var(--purple-glow);
188
+ color: var(--purple-light);
189
+ }
190
+
191
+ /* Upload Zone */
192
+ .upload-zone {
193
+ border: 2px dashed var(--border);
194
+ border-radius: var(--radius);
195
+ padding: 48px;
196
+ text-align: center;
197
+ transition: all 0.3s;
198
+ cursor: pointer;
199
+ }
200
+
201
+ .upload-zone.dragover {
202
+ border-color: var(--purple-light);
203
+ background: var(--purple-glow);
204
+ }
205
+
206
+ .upload-zone h3 {
207
+ font-family: 'Instrument Serif', serif;
208
+ font-size: 20px;
209
+ margin-bottom: 8px;
210
+ }
211
+
212
+ .upload-zone p { color: var(--text-dim); font-size: 13px; }
213
+
214
+ .upload-list {
215
+ margin-top: 16px;
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 8px;
219
+ }
220
+
221
+ .upload-item {
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: space-between;
225
+ padding: 10px 16px;
226
+ background: var(--bg-hover);
227
+ border-radius: 8px;
228
+ font-size: 13px;
229
+ }
230
+
231
+ .upload-item .status-ok { color: var(--green); }
232
+ .upload-item .status-err { color: var(--red); }
233
+
234
+ /* Graph Container */
235
+ #graph-container {
236
+ width: 100%;
237
+ height: 500px;
238
+ background: var(--bg-card);
239
+ border: 1px solid var(--border);
240
+ border-radius: var(--radius);
241
+ overflow: hidden;
242
+ position: relative;
243
+ }
244
+
245
+ #graph-container svg { width: 100%; height: 100%; }
246
+
247
+ .graph-controls {
248
+ position: absolute;
249
+ top: 12px;
250
+ right: 12px;
251
+ display: flex;
252
+ gap: 6px;
253
+ z-index: 10;
254
+ }
255
+
256
+ .graph-controls button {
257
+ background: var(--bg);
258
+ border: 1px solid var(--border);
259
+ color: var(--text-dim);
260
+ width: 32px;
261
+ height: 32px;
262
+ border-radius: 6px;
263
+ cursor: pointer;
264
+ font-size: 16px;
265
+ display: flex;
266
+ align-items: center;
267
+ justify-content: center;
268
+ transition: all 0.2s;
269
+ }
270
+
271
+ .graph-controls button:hover { border-color: var(--purple); color: var(--text); }
272
+
273
+ .node-tooltip {
274
+ position: absolute;
275
+ background: var(--bg);
276
+ border: 1px solid var(--purple);
277
+ border-radius: 8px;
278
+ padding: 10px 14px;
279
+ font-size: 12px;
280
+ pointer-events: none;
281
+ display: none;
282
+ z-index: 20;
283
+ max-width: 200px;
284
+ }
285
+
286
+ .node-tooltip .tt-title { font-weight: 600; color: var(--purple-light); }
287
+ .node-tooltip .tt-meta { color: var(--text-dim); margin-top: 4px; }
288
+
289
+ /* Query Interface */
290
+ .query-form {
291
+ display: flex;
292
+ gap: 12px;
293
+ margin-bottom: 20px;
294
+ }
295
+
296
+ .query-input {
297
+ flex: 1;
298
+ background: var(--bg);
299
+ border: 1px solid var(--border);
300
+ border-radius: 8px;
301
+ padding: 12px 16px;
302
+ color: var(--text);
303
+ font-family: 'Poppins', sans-serif;
304
+ font-size: 14px;
305
+ outline: none;
306
+ transition: border-color 0.2s;
307
+ }
308
+
309
+ .query-input:focus { border-color: var(--purple); }
310
+
311
+ .query-input::placeholder { color: var(--text-muted); }
312
+
313
+ .btn {
314
+ background: var(--purple);
315
+ border: none;
316
+ color: white;
317
+ font-family: 'Poppins', sans-serif;
318
+ font-size: 13px;
319
+ font-weight: 500;
320
+ padding: 12px 24px;
321
+ border-radius: 8px;
322
+ cursor: pointer;
323
+ transition: all 0.2s;
324
+ }
325
+
326
+ .btn:hover { background: #7c3aed; }
327
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
328
+
329
+ .query-result {
330
+ background: var(--bg);
331
+ border: 1px solid var(--border);
332
+ border-radius: var(--radius);
333
+ padding: 20px;
334
+ font-size: 14px;
335
+ line-height: 1.7;
336
+ white-space: pre-wrap;
337
+ display: none;
338
+ }
339
+
340
+ .query-result.visible { display: block; }
341
+
342
+ .query-sources {
343
+ margin-top: 12px;
344
+ padding-top: 12px;
345
+ border-top: 1px solid var(--border);
346
+ font-size: 12px;
347
+ color: var(--text-dim);
348
+ }
349
+
350
+ .query-sources span {
351
+ display: inline-block;
352
+ margin-right: 8px;
353
+ padding: 2px 8px;
354
+ background: var(--bg-hover);
355
+ border-radius: 4px;
356
+ }
357
+
358
+ /* Sections */
359
+ .section { display: none; }
360
+ .section.active { display: block; }
361
+
362
+ /* Raw files table */
363
+ .raw-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
364
+ .raw-table th {
365
+ text-align: left;
366
+ font-size: 11px;
367
+ font-weight: 600;
368
+ text-transform: uppercase;
369
+ letter-spacing: 0.5px;
370
+ color: var(--text-muted);
371
+ padding: 8px 12px;
372
+ border-bottom: 1px solid var(--border);
373
+ }
374
+ .raw-table td {
375
+ padding: 8px 12px;
376
+ font-size: 13px;
377
+ border-bottom: 1px solid var(--border);
378
+ }
379
+
380
+ /* Page Detail Modal */
381
+ .modal-overlay {
382
+ position: fixed;
383
+ inset: 0;
384
+ background: rgba(0,0,0,0.6);
385
+ display: none;
386
+ z-index: 100;
387
+ align-items: center;
388
+ justify-content: center;
389
+ }
390
+
391
+ .modal-overlay.visible { display: flex; }
392
+
393
+ .modal {
394
+ background: var(--bg-card);
395
+ border: 1px solid var(--border);
396
+ border-radius: var(--radius);
397
+ width: 90%;
398
+ max-width: 700px;
399
+ max-height: 80vh;
400
+ overflow-y: auto;
401
+ padding: 28px;
402
+ }
403
+
404
+ .modal-header {
405
+ display: flex;
406
+ justify-content: space-between;
407
+ align-items: center;
408
+ margin-bottom: 16px;
409
+ }
410
+
411
+ .modal-header h2 {
412
+ font-family: 'Instrument Serif', serif;
413
+ font-size: 24px;
414
+ }
415
+
416
+ .modal-close {
417
+ background: transparent;
418
+ border: none;
419
+ color: var(--text-dim);
420
+ font-size: 20px;
421
+ cursor: pointer;
422
+ }
423
+
424
+ .modal-body {
425
+ font-size: 14px;
426
+ line-height: 1.7;
427
+ white-space: pre-wrap;
428
+ color: var(--text-dim);
429
+ }
430
+
431
+ .modal-meta {
432
+ display: flex;
433
+ gap: 16px;
434
+ margin-bottom: 16px;
435
+ font-size: 12px;
436
+ color: var(--text-muted);
437
+ }
438
+
439
+ /* Loading spinner */
440
+ .spinner {
441
+ display: inline-block;
442
+ width: 16px;
443
+ height: 16px;
444
+ border: 2px solid var(--border);
445
+ border-top-color: var(--purple-light);
446
+ border-radius: 50%;
447
+ animation: spin 0.6s linear infinite;
448
+ }
449
+
450
+ @keyframes spin { to { transform: rotate(360deg); } }
451
+
452
+ /* Responsive */
453
+ @media (max-width: 768px) {
454
+ .header, .nav, .main { padding-left: 16px; padding-right: 16px; }
455
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
456
+ #graph-container { height: 350px; }
457
+ .query-form { flex-direction: column; }
458
+ }
459
+ </style>
460
+ </head>
461
+ <body>
462
+ <div class="header">
463
+ <h1>llm<span>wiki</span></h1>
464
+ <div class="header-status">
465
+ <div class="status-dot"></div>
466
+ <span id="header-page-count">Loading...</span>
467
+ </div>
468
+ </div>
469
+
470
+ <div class="nav">
471
+ <button class="active" data-section="dashboard">Dashboard</button>
472
+ <button data-section="pages">Pages</button>
473
+ <button data-section="graph">Knowledge Graph</button>
474
+ <button data-section="query">Query</button>
475
+ <button data-section="upload">Upload</button>
476
+ </div>
477
+
478
+ <div class="main">
479
+ <!-- Dashboard -->
480
+ <div class="section active" id="section-dashboard">
481
+ <div class="stats-grid" id="stats-grid"></div>
482
+ <div class="card">
483
+ <div class="card-title">Recent Pages</div>
484
+ <table class="pages-table">
485
+ <thead><tr><th>Title</th><th>Category</th><th>Words</th><th>Links</th></tr></thead>
486
+ <tbody id="recent-pages"></tbody>
487
+ </table>
488
+ </div>
489
+ <div class="card">
490
+ <div class="card-title">Raw Sources</div>
491
+ <table class="raw-table">
492
+ <thead><tr><th>File</th><th>Size</th><th>Modified</th></tr></thead>
493
+ <tbody id="raw-files"></tbody>
494
+ </table>
495
+ </div>
496
+ </div>
497
+
498
+ <!-- Pages -->
499
+ <div class="section" id="section-pages">
500
+ <div class="card">
501
+ <div class="card-title">All Wiki Pages</div>
502
+ <table class="pages-table">
503
+ <thead><tr><th>Title</th><th>Category</th><th>Words</th><th>Links</th></tr></thead>
504
+ <tbody id="all-pages"></tbody>
505
+ </table>
506
+ </div>
507
+ </div>
508
+
509
+ <!-- Graph -->
510
+ <div class="section" id="section-graph">
511
+ <div id="graph-container">
512
+ <div class="graph-controls">
513
+ <button id="graph-zoom-in" title="Zoom in">+</button>
514
+ <button id="graph-zoom-out" title="Zoom out">−</button>
515
+ <button id="graph-reset" title="Reset">⟲</button>
516
+ </div>
517
+ <div class="node-tooltip" id="tooltip"></div>
518
+ <svg id="graph-svg"></svg>
519
+ </div>
520
+ </div>
521
+
522
+ <!-- Query -->
523
+ <div class="section" id="section-query">
524
+ <div class="card">
525
+ <div class="card-title">Ask your knowledge base</div>
526
+ <div class="query-form">
527
+ <input type="text" class="query-input" id="query-input" placeholder="What do you want to know?" />
528
+ <button class="btn" id="query-btn">Ask</button>
529
+ </div>
530
+ <div class="query-result" id="query-result"></div>
531
+ </div>
532
+ </div>
533
+
534
+ <!-- Upload -->
535
+ <div class="section" id="section-upload">
536
+ <div class="upload-zone" id="upload-zone">
537
+ <h3>Drop files here</h3>
538
+ <p>Markdown, PDF, DOCX, XLSX, PPTX, text, or URLs</p>
539
+ <p style="margin-top:8px;font-size:12px;color:var(--text-muted);">Click to browse or drag &amp; drop</p>
540
+ <input type="file" id="file-input" multiple style="display:none" accept=".md,.txt,.pdf,.docx,.xlsx,.pptx,.csv,.json,.yaml,.yml" />
541
+ </div>
542
+ <div class="upload-list" id="upload-list"></div>
543
+ </div>
544
+ </div>
545
+
546
+ <!-- Page Detail Modal -->
547
+ <div class="modal-overlay" id="page-modal">
548
+ <div class="modal">
549
+ <div class="modal-header">
550
+ <h2 id="modal-title"></h2>
551
+ <button class="modal-close" id="modal-close">&times;</button>
552
+ </div>
553
+ <div class="modal-meta" id="modal-meta"></div>
554
+ <div class="modal-body" id="modal-body"></div>
555
+ </div>
556
+ </div>
557
+
558
+ <script>
559
+ // ── State ──
560
+ let currentSection = 'dashboard';
561
+
562
+ // ── Navigation ──
563
+ document.querySelectorAll('.nav button').forEach(btn => {
564
+ btn.addEventListener('click', () => {
565
+ document.querySelectorAll('.nav button').forEach(b => b.classList.remove('active'));
566
+ btn.classList.add('active');
567
+ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
568
+ const section = btn.dataset.section;
569
+ document.getElementById('section-' + section).classList.add('active');
570
+ currentSection = section;
571
+ if (section === 'graph') initGraph();
572
+ });
573
+ });
574
+
575
+ // ── API Helpers ──
576
+ async function api(path) {
577
+ const res = await fetch(path);
578
+ return res.json();
579
+ }
580
+
581
+ function formatBytes(bytes) {
582
+ if (bytes < 1024) return bytes + ' B';
583
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
584
+ return (bytes / 1048576).toFixed(1) + ' MB';
585
+ }
586
+
587
+ function formatDate(iso) {
588
+ return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
589
+ }
590
+
591
+ // ── Dashboard ──
592
+ async function loadDashboard() {
593
+ const [stats, pages, raw] = await Promise.all([
594
+ api('/api/status'),
595
+ api('/api/pages'),
596
+ api('/api/raw'),
597
+ ]);
598
+
599
+ document.getElementById('header-page-count').textContent = stats.pageCount + ' pages · ' + stats.wordCount.toLocaleString() + ' words';
600
+
601
+ const statsGrid = document.getElementById('stats-grid');
602
+ const statItems = [
603
+ { value: stats.pageCount, label: 'Wiki Pages' },
604
+ { value: stats.wordCount.toLocaleString(), label: 'Total Words' },
605
+ { value: stats.sourceCount, label: 'Raw Sources' },
606
+ { value: stats.wikilinks, label: 'Wiki Links' },
607
+ { value: stats.orphanPages, label: 'Orphan Pages' },
608
+ { value: stats.lastUpdated, label: 'Last Updated' },
609
+ ];
610
+ statsGrid.innerHTML = statItems.map(s =>
611
+ `<div class="stat-card"><div class="stat-value">${s.value}</div><div class="stat-label">${s.label}</div></div>`
612
+ ).join('');
613
+
614
+ // Recent pages (last 10)
615
+ const recentPages = pages.slice(-10).reverse();
616
+ document.getElementById('recent-pages').innerHTML = recentPages.map(p =>
617
+ `<tr>
618
+ <td class="title-cell" data-title="${esc(p.title)}">${esc(p.title)}</td>
619
+ <td><span class="category-badge">${esc(p.category)}</span></td>
620
+ <td>${p.wordCount}</td>
621
+ <td>${p.wikilinks.length}</td>
622
+ </tr>`
623
+ ).join('');
624
+
625
+ // All pages
626
+ document.getElementById('all-pages').innerHTML = pages.map(p =>
627
+ `<tr>
628
+ <td class="title-cell" data-title="${esc(p.title)}">${esc(p.title)}</td>
629
+ <td><span class="category-badge">${esc(p.category)}</span></td>
630
+ <td>${p.wordCount}</td>
631
+ <td>${p.wikilinks.length}</td>
632
+ </tr>`
633
+ ).join('');
634
+
635
+ // Raw files
636
+ document.getElementById('raw-files').innerHTML = raw.length === 0
637
+ ? '<tr><td colspan="3" style="color:var(--text-muted);text-align:center;padding:20px;">No raw sources yet. Upload files to get started.</td></tr>'
638
+ : raw.map(f =>
639
+ `<tr><td>${esc(f.name)}</td><td>${formatBytes(f.size)}</td><td>${formatDate(f.modified)}</td></tr>`
640
+ ).join('');
641
+
642
+ // Click handlers for page titles
643
+ document.querySelectorAll('.title-cell').forEach(td => {
644
+ td.addEventListener('click', () => openPage(td.dataset.title));
645
+ });
646
+ }
647
+
648
+ function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
649
+
650
+ // ── Page Modal ──
651
+ async function openPage(title) {
652
+ const page = await api('/api/pages/' + encodeURIComponent(title));
653
+ document.getElementById('modal-title').textContent = page.title;
654
+ document.getElementById('modal-meta').innerHTML =
655
+ `<span>${page.wordCount} words</span>` +
656
+ `<span>${page.wikilinks.length} links</span>` +
657
+ (page.frontmatter.category ? `<span>${page.frontmatter.category}</span>` : '');
658
+ document.getElementById('modal-body').textContent = page.content;
659
+ document.getElementById('page-modal').classList.add('visible');
660
+ }
661
+
662
+ document.getElementById('modal-close').addEventListener('click', () => {
663
+ document.getElementById('page-modal').classList.remove('visible');
664
+ });
665
+ document.getElementById('page-modal').addEventListener('click', (e) => {
666
+ if (e.target === e.currentTarget) e.currentTarget.classList.remove('visible');
667
+ });
668
+
669
+ // ── Knowledge Graph (d3-force) ──
670
+ let graphInitialized = false;
671
+
672
+ async function initGraph() {
673
+ if (graphInitialized) return;
674
+ graphInitialized = true;
675
+
676
+ const data = await api('/api/graph');
677
+ if (data.nodes.length === 0) {
678
+ document.getElementById('graph-container').innerHTML =
679
+ '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;">No pages yet. Ingest some content first.</div>';
680
+ return;
681
+ }
682
+
683
+ const container = document.getElementById('graph-container');
684
+ const width = container.clientWidth;
685
+ const height = container.clientHeight;
686
+ const svg = d3.select('#graph-svg');
687
+ const tooltip = document.getElementById('tooltip');
688
+
689
+ // Category colors
690
+ const catColors = {
691
+ uncategorized: '#6b21a8',
692
+ concept: '#2563eb',
693
+ reference: '#059669',
694
+ project: '#d97706',
695
+ person: '#dc2626',
696
+ tool: '#7c3aed',
697
+ note: '#6366f1',
698
+ };
699
+
700
+ function nodeColor(cat) { return catColors[cat] || '#6b21a8'; }
701
+
702
+ const g = svg.append('g');
703
+
704
+ const zoom = d3.zoom()
705
+ .scaleExtent([0.2, 5])
706
+ .on('zoom', (e) => g.attr('transform', e.transform));
707
+
708
+ svg.call(zoom);
709
+
710
+ // Zoom controls
711
+ document.getElementById('graph-zoom-in').addEventListener('click', () => svg.transition().call(zoom.scaleBy, 1.3));
712
+ document.getElementById('graph-zoom-out').addEventListener('click', () => svg.transition().call(zoom.scaleBy, 0.7));
713
+ document.getElementById('graph-reset').addEventListener('click', () => svg.transition().call(zoom.transform, d3.zoomIdentity));
714
+
715
+ const simulation = d3.forceSimulation(data.nodes)
716
+ .force('link', d3.forceLink(data.links).id(d => d.id).distance(80))
717
+ .force('charge', d3.forceManyBody().strength(-200))
718
+ .force('center', d3.forceCenter(width / 2, height / 2))
719
+ .force('collision', d3.forceCollide().radius(20));
720
+
721
+ // Links
722
+ const link = g.append('g')
723
+ .selectAll('line')
724
+ .data(data.links)
725
+ .join('line')
726
+ .attr('stroke', '#2a2928')
727
+ .attr('stroke-width', 1)
728
+ .attr('stroke-opacity', 0.6);
729
+
730
+ // Nodes
731
+ const node = g.append('g')
732
+ .selectAll('circle')
733
+ .data(data.nodes)
734
+ .join('circle')
735
+ .attr('r', d => Math.max(6, Math.min(20, Math.sqrt(d.wordCount / 50))))
736
+ .attr('fill', d => nodeColor(d.category))
737
+ .attr('stroke', '#141312')
738
+ .attr('stroke-width', 1.5)
739
+ .attr('cursor', 'pointer')
740
+ .call(d3.drag()
741
+ .on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
742
+ .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
743
+ .on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })
744
+ );
745
+
746
+ // Labels
747
+ const label = g.append('g')
748
+ .selectAll('text')
749
+ .data(data.nodes)
750
+ .join('text')
751
+ .text(d => d.title.length > 20 ? d.title.slice(0, 18) + '…' : d.title)
752
+ .attr('font-size', 10)
753
+ .attr('font-family', 'Poppins, sans-serif')
754
+ .attr('fill', '#8a8580')
755
+ .attr('text-anchor', 'middle')
756
+ .attr('dy', d => Math.max(6, Math.min(20, Math.sqrt(d.wordCount / 50))) + 14);
757
+
758
+ // Tooltip
759
+ node.on('mouseover', (e, d) => {
760
+ tooltip.style.display = 'block';
761
+ tooltip.innerHTML = `<div class="tt-title">${esc(d.title)}</div><div class="tt-meta">${d.wordCount} words · ${d.linksOut} out · ${d.linksIn} in<br>${d.category}</div>`;
762
+ }).on('mousemove', (e) => {
763
+ tooltip.style.left = (e.offsetX + 12) + 'px';
764
+ tooltip.style.top = (e.offsetY - 10) + 'px';
765
+ }).on('mouseout', () => {
766
+ tooltip.style.display = 'none';
767
+ }).on('click', (e, d) => {
768
+ openPage(d.id);
769
+ });
770
+
771
+ simulation.on('tick', () => {
772
+ link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
773
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
774
+ node.attr('cx', d => d.x).attr('cy', d => d.y);
775
+ label.attr('x', d => d.x).attr('y', d => d.y);
776
+ });
777
+ }
778
+
779
+ // ── Query ──
780
+ document.getElementById('query-btn').addEventListener('click', submitQuery);
781
+ document.getElementById('query-input').addEventListener('keydown', (e) => {
782
+ if (e.key === 'Enter') submitQuery();
783
+ });
784
+
785
+ async function submitQuery() {
786
+ const input = document.getElementById('query-input');
787
+ const resultEl = document.getElementById('query-result');
788
+ const btn = document.getElementById('query-btn');
789
+ const question = input.value.trim();
790
+ if (!question) return;
791
+
792
+ btn.disabled = true;
793
+ btn.innerHTML = '<span class="spinner"></span>';
794
+ resultEl.classList.add('visible');
795
+ resultEl.innerHTML = '<span class="spinner"></span> Searching knowledge base...';
796
+
797
+ try {
798
+ const res = await fetch('/api/query?q=' + encodeURIComponent(question));
799
+ const data = await res.json();
800
+ if (data.error) {
801
+ resultEl.textContent = 'Error: ' + data.error;
802
+ } else {
803
+ resultEl.innerHTML = esc(data.answer || 'No answer found.');
804
+ if (data.sourcesConsulted && data.sourcesConsulted.length > 0) {
805
+ resultEl.innerHTML += '<div class="query-sources">Sources: ' +
806
+ data.sourcesConsulted.map(s => '<span>' + esc(s) + '</span>').join('') +
807
+ '</div>';
808
+ }
809
+ }
810
+ } catch (err) {
811
+ resultEl.textContent = 'Query failed. Make sure you have an LLM provider configured.';
812
+ }
813
+
814
+ btn.disabled = false;
815
+ btn.textContent = 'Ask';
816
+ }
817
+
818
+ // ── Upload ──
819
+ const uploadZone = document.getElementById('upload-zone');
820
+ const fileInput = document.getElementById('file-input');
821
+ const uploadList = document.getElementById('upload-list');
822
+
823
+ uploadZone.addEventListener('click', () => fileInput.click());
824
+
825
+ uploadZone.addEventListener('dragover', (e) => {
826
+ e.preventDefault();
827
+ uploadZone.classList.add('dragover');
828
+ });
829
+
830
+ uploadZone.addEventListener('dragleave', () => {
831
+ uploadZone.classList.remove('dragover');
832
+ });
833
+
834
+ uploadZone.addEventListener('drop', (e) => {
835
+ e.preventDefault();
836
+ uploadZone.classList.remove('dragover');
837
+ if (e.dataTransfer.files.length > 0) uploadFiles(e.dataTransfer.files);
838
+ });
839
+
840
+ fileInput.addEventListener('change', () => {
841
+ if (fileInput.files.length > 0) uploadFiles(fileInput.files);
842
+ });
843
+
844
+ async function uploadFiles(files) {
845
+ for (const file of files) {
846
+ const item = document.createElement('div');
847
+ item.className = 'upload-item';
848
+ item.innerHTML = `<span>${esc(file.name)}</span><span class="spinner"></span>`;
849
+ uploadList.prepend(item);
850
+
851
+ try {
852
+ const buf = await file.arrayBuffer();
853
+ const res = await fetch('/api/upload', {
854
+ method: 'POST',
855
+ headers: { 'Content-Type': 'application/octet-stream', 'X-Filename': file.name },
856
+ body: buf,
857
+ });
858
+ const data = await res.json();
859
+ item.innerHTML = `<span>${esc(file.name)} (${formatBytes(file.size)})</span><span class="status-ok">uploaded</span>`;
860
+ } catch (err) {
861
+ item.innerHTML = `<span>${esc(file.name)}</span><span class="status-err">failed</span>`;
862
+ }
863
+ }
864
+ // Refresh dashboard
865
+ loadDashboard();
866
+ }
867
+
868
+ // ── Init ──
869
+ loadDashboard();
870
+ </script>
871
+ </body>
872
+ </html>