ltcai 0.1.9 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +174 -305
  2. package/docs/CHANGELOG.md +307 -0
  3. package/docs/architecture.md +121 -0
  4. package/docs/mcp-tools.md +116 -0
  5. package/docs/privacy.md +74 -0
  6. package/docs/public-deploy.md +137 -0
  7. package/docs/security-model.md +121 -0
  8. package/knowledge_graph.py +123 -15
  9. package/llm_router.py +100 -28
  10. package/ltcai_cli.py +138 -5
  11. package/package.json +14 -2
  12. package/server.py +1756 -329
  13. package/skills/SKILL_TEMPLATE.md +61 -29
  14. package/skills/code_review/SKILL.md +28 -0
  15. package/skills/code_review/examples.md +59 -0
  16. package/skills/code_review/risk.json +9 -0
  17. package/skills/code_review/schema.json +65 -0
  18. package/skills/data_analysis/SKILL.md +28 -0
  19. package/skills/data_analysis/examples.md +62 -0
  20. package/skills/data_analysis/risk.json +9 -0
  21. package/skills/data_analysis/schema.json +61 -0
  22. package/skills/file_edit/SKILL.md +33 -0
  23. package/skills/file_edit/examples.md +45 -0
  24. package/skills/file_edit/risk.json +9 -0
  25. package/skills/file_edit/schema.json +60 -0
  26. package/skills/summarize_document/SKILL.md +68 -0
  27. package/skills/summarize_document/examples.md +65 -0
  28. package/skills/summarize_document/risk.json +9 -0
  29. package/skills/summarize_document/schema.json +71 -0
  30. package/skills/web_search/SKILL.md +28 -0
  31. package/skills/web_search/examples.md +61 -0
  32. package/skills/web_search/risk.json +9 -0
  33. package/skills/web_search/schema.json +62 -0
  34. package/static/account.html +53 -51
  35. package/static/admin.html +50 -46
  36. package/static/chat.html +124 -96
  37. package/static/graph.html +1231 -337
  38. package/static/manifest.json +2 -2
  39. package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
  41. package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
  42. package/tests/unit/test_tools.py +194 -1
  43. package/tools.py +264 -4
package/static/graph.html CHANGED
@@ -3,112 +3,548 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Lattice AI Knowledge Graph</title>
6
+ <title>Lattice AI - Data Graph</title>
7
7
  <style>
8
8
  :root {
9
9
  color-scheme: dark;
10
- --bg: #101215;
11
- --panel: #161b20;
12
- --line: #252d35;
13
- --text: #eef2f6;
14
- --muted: #8a96a3;
15
- --faint: #4e5a64;
16
- --accent: #54d6a5;
10
+ --bg: #282a36;
11
+ --bg-soft: #222431;
12
+ --panel: rgba(34, 36, 49, 0.76);
13
+ --panel-strong: rgba(31, 33, 45, 0.9);
14
+ --line: rgba(164, 180, 242, 0.18);
15
+ --line-strong: rgba(218, 225, 255, 0.28);
16
+ --text: #f7f7f2;
17
+ --muted: #c4c8d8;
18
+ --faint: #8d93ab;
19
+ --accent: #a77cff;
20
+ --accent-2: #20b8aa;
21
+ --danger: #ef7f7f;
22
+ --shadow: 0 18px 60px rgba(4, 6, 12, 0.34);
17
23
  }
24
+
18
25
  * { box-sizing: border-box; }
19
- body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; overflow: hidden; }
20
- .app { display: grid; grid-template-columns: 1fr 320px; height: 100vh; }
26
+ html, body { height: 100%; }
27
+ body {
28
+ margin: 0;
29
+ overflow: hidden;
30
+ color: var(--text);
31
+ background:
32
+ radial-gradient(circle at 50% 42%, rgba(167,124,255,0.10), transparent 34%),
33
+ radial-gradient(circle at 70% 22%, rgba(32,184,170,0.08), transparent 28%),
34
+ linear-gradient(180deg, #2b2d3a, #282a36 62%, #242632);
35
+ font-family: "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
36
+ }
37
+
38
+ .app {
39
+ display: grid;
40
+ grid-template-columns: minmax(0, 1fr) 360px;
41
+ height: 100vh;
42
+ }
43
+
44
+ .stage {
45
+ position: relative;
46
+ min-width: 0;
47
+ border-right: 1px solid var(--line);
48
+ background:
49
+ radial-gradient(circle, rgba(247,247,242,0.62) 1px, transparent 1.9px),
50
+ linear-gradient(116deg, transparent 0 43%, rgba(120,145,220,0.10) 43.1%, transparent 43.28% 100%),
51
+ linear-gradient(28deg, transparent 0 61%, rgba(167,124,255,0.09) 61.1%, transparent 61.28% 100%);
52
+ background-size: 84px 84px, 100% 100%, 100% 100%;
53
+ background-position: 18px 22px, 0 0, 0 0;
54
+ }
55
+
56
+ canvas {
57
+ display: block;
58
+ width: 100%;
59
+ height: 100%;
60
+ cursor: grab;
61
+ }
21
62
 
22
- /* Canvas area */
23
- .stage { position: relative; min-width: 0; border-right: 1px solid var(--line); }
24
- canvas { display: block; width: 100%; height: 100%; cursor: grab; }
25
63
  canvas.panning { cursor: grabbing; }
26
64
 
65
+ .search-shell,
66
+ .toolbar {
67
+ position: absolute;
68
+ z-index: 20;
69
+ border: 1px solid var(--line);
70
+ background: var(--panel);
71
+ backdrop-filter: blur(18px);
72
+ box-shadow: var(--shadow);
73
+ }
74
+
75
+ .search-shell {
76
+ top: 16px;
77
+ left: 16px;
78
+ width: min(360px, calc(100% - 32px));
79
+ border-radius: 10px;
80
+ overflow: hidden;
81
+ }
82
+
83
+ .search-head {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: space-between;
87
+ gap: 12px;
88
+ padding: 14px 16px 10px;
89
+ border-bottom: 1px solid rgba(255,255,255,0.04);
90
+ }
91
+
92
+ .search-title {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 3px;
96
+ }
97
+
98
+ .search-title strong {
99
+ font-size: 14px;
100
+ font-weight: 700;
101
+ letter-spacing: 0.01em;
102
+ }
103
+
104
+ .search-title span {
105
+ font-size: 12px;
106
+ color: var(--muted);
107
+ }
108
+
109
+ .search-count {
110
+ flex-shrink: 0;
111
+ font-size: 11px;
112
+ color: #08100d;
113
+ background: linear-gradient(135deg, #7df0c3, #56d4ff);
114
+ border-radius: 999px;
115
+ padding: 5px 10px;
116
+ font-weight: 700;
117
+ }
118
+
119
+ .search-input-wrap {
120
+ padding: 12px 16px 10px;
121
+ }
122
+
123
+ .search-input-row {
124
+ display: flex;
125
+ gap: 8px;
126
+ align-items: center;
127
+ }
128
+
129
+ .search-input {
130
+ flex: 1;
131
+ height: 42px;
132
+ border-radius: 12px;
133
+ border: 1px solid var(--line-strong);
134
+ background: rgba(6, 10, 14, 0.88);
135
+ color: var(--text);
136
+ padding: 0 14px;
137
+ font-size: 14px;
138
+ outline: none;
139
+ }
140
+
141
+ .search-input:focus {
142
+ border-color: rgba(98, 224, 176, 0.75);
143
+ box-shadow: 0 0 0 4px rgba(98, 224, 176, 0.12);
144
+ }
145
+
146
+ .icon-btn,
147
+ .tb-btn {
148
+ height: 42px;
149
+ border-radius: 8px;
150
+ border: 1px solid var(--line-strong);
151
+ background: rgba(255,255,255,0.03);
152
+ color: var(--text);
153
+ cursor: pointer;
154
+ font-size: 13px;
155
+ transition: 140ms ease;
156
+ }
157
+
158
+ .icon-btn {
159
+ width: 42px;
160
+ flex-shrink: 0;
161
+ font-size: 16px;
162
+ }
163
+
164
+ .icon-btn:hover,
165
+ .tb-btn:hover {
166
+ transform: translateY(-1px);
167
+ border-color: rgba(98, 224, 176, 0.6);
168
+ color: var(--accent);
169
+ background: rgba(98, 224, 176, 0.07);
170
+ }
171
+
172
+ .search-results {
173
+ max-height: min(420px, calc(100vh - 180px));
174
+ overflow-y: auto;
175
+ padding: 0 8px 10px;
176
+ }
177
+
178
+ .search-empty,
179
+ .search-loading {
180
+ margin: 0;
181
+ padding: 14px 12px 16px;
182
+ color: var(--muted);
183
+ font-size: 13px;
184
+ line-height: 1.65;
185
+ }
186
+
187
+ .search-list {
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 8px;
191
+ padding: 0 8px 8px;
192
+ }
193
+
194
+ .search-item {
195
+ width: 100%;
196
+ text-align: left;
197
+ border: 1px solid transparent;
198
+ border-radius: 8px;
199
+ background: rgba(255,255,255,0.03);
200
+ color: var(--text);
201
+ padding: 12px 12px 13px;
202
+ cursor: pointer;
203
+ transition: 140ms ease;
204
+ }
205
+
206
+ .search-item:hover,
207
+ .search-item.active {
208
+ border-color: rgba(122, 168, 255, 0.45);
209
+ background: linear-gradient(180deg, rgba(122, 168, 255, 0.10), rgba(98, 224, 176, 0.05));
210
+ transform: translateY(-1px);
211
+ }
212
+
213
+ .search-item-top {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ margin-bottom: 6px;
218
+ }
219
+
220
+ .search-type {
221
+ display: inline-flex;
222
+ align-items: center;
223
+ border-radius: 999px;
224
+ padding: 4px 10px;
225
+ font-size: 11px;
226
+ font-weight: 700;
227
+ color: #091019;
228
+ }
229
+
230
+ .search-item-title {
231
+ font-size: 13px;
232
+ font-weight: 700;
233
+ letter-spacing: 0.01em;
234
+ color: var(--text);
235
+ }
236
+
237
+ .search-item-summary {
238
+ font-size: 12px;
239
+ color: var(--muted);
240
+ line-height: 1.55;
241
+ margin: 0 0 8px;
242
+ word-break: break-word;
243
+ display: -webkit-box;
244
+ -webkit-line-clamp: 3;
245
+ line-clamp: 3;
246
+ -webkit-box-orient: vertical;
247
+ overflow: hidden;
248
+ }
249
+
250
+ .search-item-meta {
251
+ font-size: 11px;
252
+ color: var(--faint);
253
+ display: flex;
254
+ gap: 10px;
255
+ flex-wrap: wrap;
256
+ }
257
+
27
258
  .toolbar {
28
- position: absolute; top: 14px; left: 14px;
29
- display: flex; gap: 6px; align-items: center;
30
- padding: 7px; background: rgba(16,18,21,0.9);
31
- border: 1px solid var(--line); border-radius: 8px;
32
- backdrop-filter: blur(14px);
33
- }
34
- .toolbar input {
35
- width: 220px; height: 32px; border: 1px solid var(--line);
36
- border-radius: 6px; background: #0b0d10; color: var(--text);
37
- padding: 0 10px; outline: none; font-size: 13px;
38
- }
39
- .toolbar input:focus { border-color: var(--accent); }
259
+ top: 16px;
260
+ right: 16px;
261
+ display: flex;
262
+ gap: 8px;
263
+ padding: 8px;
264
+ border-radius: 10px;
265
+ }
266
+
40
267
  .tb-btn {
41
- height: 32px; border: 1px solid var(--line); border-radius: 6px;
42
- background: #1c2228; color: var(--text); cursor: pointer;
43
- padding: 0 12px; font-size: 13px; white-space: nowrap;
268
+ padding: 0 14px;
269
+ min-width: 72px;
270
+ white-space: nowrap;
44
271
  }
45
- .tb-btn:hover { border-color: var(--accent); color: var(--accent); }
46
272
 
47
273
  #tooltip {
48
- position: fixed; background: rgba(16,18,21,0.96); border: 1px solid var(--line);
49
- border-radius: 6px; padding: 5px 10px; font-size: 12px;
50
- pointer-events: none; z-index: 200; max-width: 280px;
51
- word-break: break-word; display: none;
274
+ position: fixed;
275
+ z-index: 50;
276
+ max-width: 300px;
277
+ padding: 8px 11px;
278
+ border-radius: 10px;
279
+ border: 1px solid var(--line-strong);
280
+ background: rgba(7, 11, 15, 0.96);
281
+ color: var(--text);
282
+ box-shadow: var(--shadow);
283
+ font-size: 12px;
284
+ line-height: 1.45;
285
+ pointer-events: none;
286
+ display: none;
287
+ word-break: break-word;
52
288
  }
53
289
 
54
- /* Sidebar */
55
290
  aside {
56
- background: var(--panel); display: flex; flex-direction: column;
291
+ display: flex;
292
+ flex-direction: column;
293
+ background:
294
+ linear-gradient(180deg, rgba(34, 36, 49, 0.88), rgba(28, 30, 41, 0.92)),
295
+ var(--bg-soft);
57
296
  overflow: hidden;
58
297
  }
298
+
59
299
  .sidebar-head {
60
- padding: 16px 16px 0; flex-shrink: 0;
300
+ padding: 18px 18px 12px;
301
+ border-bottom: 1px solid rgba(255,255,255,0.04);
302
+ }
303
+
304
+ .eyebrow {
305
+ color: var(--accent);
306
+ font-size: 11px;
307
+ font-weight: 700;
308
+ letter-spacing: 0.12em;
309
+ text-transform: uppercase;
310
+ margin-bottom: 8px;
311
+ }
312
+
313
+ h1 {
314
+ margin: 0;
315
+ font-size: 22px;
316
+ line-height: 1.1;
317
+ letter-spacing: -0.02em;
318
+ }
319
+
320
+ .sidebar-sub {
321
+ margin: 10px 0 0;
322
+ color: var(--muted);
323
+ font-size: 13px;
324
+ line-height: 1.65;
61
325
  }
62
- h1 { margin: 0 0 12px; font-size: 17px; font-weight: 700; }
63
326
 
64
327
  .stats-row {
65
- display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;
328
+ display: grid;
329
+ grid-template-columns: 1fr 1fr;
330
+ gap: 10px;
331
+ margin-top: 14px;
66
332
  }
333
+
67
334
  .stat {
68
- border: 1px solid var(--line); border-radius: 8px;
69
- padding: 9px 10px; background: #0e1114;
335
+ border: 1px solid var(--line);
336
+ border-radius: 8px;
337
+ background: rgba(6, 10, 14, 0.34);
338
+ padding: 12px 13px;
339
+ }
340
+
341
+ .stat strong {
342
+ display: block;
343
+ font-size: 24px;
344
+ line-height: 1;
345
+ margin-bottom: 5px;
346
+ }
347
+
348
+ .stat span {
349
+ display: block;
350
+ font-size: 11px;
351
+ color: var(--muted);
352
+ text-transform: uppercase;
353
+ letter-spacing: 0.08em;
354
+ }
355
+
356
+ .section {
357
+ padding: 16px 18px 0;
358
+ flex-shrink: 0;
359
+ }
360
+
361
+ .section-label {
362
+ color: var(--faint);
363
+ font-size: 11px;
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.08em;
366
+ font-weight: 700;
367
+ margin-bottom: 10px;
368
+ }
369
+
370
+ .legend-grid,
371
+ .filter-grid {
372
+ display: flex;
373
+ flex-direction: column;
374
+ gap: 7px;
70
375
  }
71
- .stat strong { display: block; font-size: 20px; font-weight: 700; line-height: 1; margin-bottom: 3px; }
72
- .stat span { color: var(--muted); font-size: 11px; }
73
376
 
74
- .type-filters { padding: 10px 16px 8px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
75
- .filter-section-label {
76
- font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
77
- text-transform: uppercase; color: var(--faint); margin-bottom: 7px;
377
+ .legend-item,
378
+ .filter-item {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 9px;
382
+ min-height: 26px;
383
+ font-size: 12px;
78
384
  }
79
- .filter-grid { display: flex; flex-direction: column; gap: 3px; }
385
+
80
386
  .filter-item {
81
- display: flex; align-items: center; gap: 7px;
82
- padding: 3px 0; cursor: pointer; font-size: 12px; user-select: none;
387
+ cursor: pointer;
388
+ user-select: none;
389
+ }
390
+
391
+ .filter-item input[type="checkbox"] {
392
+ width: 14px;
393
+ height: 14px;
394
+ margin: 0;
395
+ accent-color: var(--accent);
396
+ cursor: pointer;
397
+ }
398
+
399
+ .dot {
400
+ width: 10px;
401
+ height: 10px;
402
+ border-radius: 50%;
403
+ flex-shrink: 0;
404
+ box-shadow: 0 0 0 3px rgba(255,255,255,0.03);
405
+ }
406
+
407
+ .legend-line {
408
+ width: 18px;
409
+ height: 0;
410
+ border-top: 2px solid #fff;
411
+ opacity: 0.8;
412
+ flex-shrink: 0;
413
+ }
414
+
415
+ .filter-name,
416
+ .legend-name {
417
+ flex: 1;
418
+ color: var(--text);
419
+ }
420
+
421
+ .filter-count,
422
+ .legend-meta {
423
+ color: var(--faint);
424
+ font-size: 11px;
425
+ text-align: right;
426
+ white-space: nowrap;
427
+ }
428
+
429
+ .detail-wrap {
430
+ flex: 1;
431
+ overflow-y: auto;
432
+ padding: 18px;
83
433
  }
84
- .filter-item input[type="checkbox"] { width: 13px; height: 13px; cursor: pointer; accent-color: var(--accent); }
85
- .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
86
- .filter-name { flex: 1; color: var(--text); }
87
- .filter-count { color: var(--faint); font-size: 11px; min-width: 20px; text-align: right; }
88
434
 
89
- .detail-wrap { flex: 1; overflow-y: auto; padding: 14px 16px 20px; }
90
435
  .type-badge {
91
- display: inline-block; color: #07100d; border-radius: 999px;
92
- padding: 2px 10px; font-size: 11px; font-weight: 700; margin-bottom: 9px;
436
+ display: inline-flex;
437
+ align-items: center;
438
+ border-radius: 999px;
439
+ padding: 5px 11px;
440
+ font-size: 11px;
441
+ font-weight: 700;
442
+ color: #091019;
443
+ margin-bottom: 10px;
93
444
  }
94
- .detail-title { font-size: 15px; font-weight: 700; line-height: 1.35; margin-bottom: 6px; }
95
- .detail-summary { color: var(--muted); font-size: 12px; line-height: 1.6; word-break: break-word; white-space: pre-wrap; margin-bottom: 10px; }
96
- .detail-meta { color: var(--faint); font-size: 11px; line-height: 1.5; word-break: break-word; white-space: pre-wrap; }
445
+
446
+ .detail-title {
447
+ font-size: 19px;
448
+ line-height: 1.28;
449
+ font-weight: 750;
450
+ margin-bottom: 8px;
451
+ letter-spacing: -0.01em;
452
+ }
453
+
454
+ .detail-summary {
455
+ color: var(--muted);
456
+ font-size: 13px;
457
+ line-height: 1.7;
458
+ white-space: pre-wrap;
459
+ word-break: break-word;
460
+ margin-bottom: 14px;
461
+ }
462
+
463
+ .metric-grid {
464
+ display: grid;
465
+ grid-template-columns: repeat(2, minmax(0, 1fr));
466
+ gap: 10px;
467
+ margin-bottom: 14px;
468
+ }
469
+
470
+ .metric-card {
471
+ border: 1px solid var(--line);
472
+ border-radius: 8px;
473
+ padding: 11px 12px;
474
+ background: rgba(255,255,255,0.03);
475
+ }
476
+
477
+ .metric-card strong {
478
+ display: block;
479
+ font-size: 18px;
480
+ line-height: 1;
481
+ margin-bottom: 5px;
482
+ }
483
+
484
+ .metric-card span {
485
+ display: block;
486
+ font-size: 11px;
487
+ color: var(--muted);
488
+ text-transform: uppercase;
489
+ letter-spacing: 0.08em;
490
+ }
491
+
492
+ .meta-block {
493
+ border: 1px solid var(--line);
494
+ border-radius: 8px;
495
+ background: rgba(255,255,255,0.02);
496
+ padding: 12px;
497
+ color: var(--muted);
498
+ font-size: 12px;
499
+ line-height: 1.65;
500
+ white-space: pre-wrap;
501
+ word-break: break-word;
502
+ }
503
+
97
504
  .jump-btn {
98
- display: inline-flex; align-items: center; gap: 5px;
99
- margin: 8px 0 12px; padding: 6px 13px;
100
- background: rgba(84,214,165,0.1); border: 1px solid rgba(84,214,165,0.28);
101
- border-radius: 6px; color: var(--accent); font-size: 12px;
102
- text-decoration: none; cursor: pointer;
505
+ display: inline-flex;
506
+ align-items: center;
507
+ gap: 6px;
508
+ margin: 0 0 14px;
509
+ padding: 8px 14px;
510
+ border-radius: 999px;
511
+ text-decoration: none;
512
+ font-size: 12px;
513
+ font-weight: 700;
514
+ color: #07120d;
515
+ background: linear-gradient(135deg, #f7f7f2, #cfc6ff);
516
+ box-shadow: 0 10px 26px rgba(167, 124, 255, 0.18);
103
517
  }
104
- .jump-btn:hover { background: rgba(84,214,165,0.18); }
105
- .empty-hint { color: var(--muted); font-size: 13px; line-height: 1.65; margin: 0; }
106
518
 
107
- @media (max-width: 760px) {
108
- .app { grid-template-columns: 1fr; grid-template-rows: 1fr 300px; }
109
- .stage { border-right: 0; border-bottom: 1px solid var(--line); }
110
- .toolbar { right: 12px; left: 12px; }
111
- .toolbar input { flex: 1; width: auto; }
519
+ .jump-btn:hover { filter: brightness(1.04); }
520
+
521
+ .empty-hint {
522
+ margin: 0;
523
+ color: var(--muted);
524
+ font-size: 13px;
525
+ line-height: 1.8;
526
+ }
527
+
528
+ @media (max-width: 900px) {
529
+ .app {
530
+ grid-template-columns: 1fr;
531
+ grid-template-rows: 1fr 360px;
532
+ }
533
+
534
+ .stage {
535
+ border-right: 0;
536
+ border-bottom: 1px solid var(--line);
537
+ }
538
+
539
+ .search-shell {
540
+ width: calc(100% - 32px);
541
+ }
542
+
543
+ .toolbar {
544
+ top: auto;
545
+ bottom: 16px;
546
+ right: 16px;
547
+ }
112
548
  }
113
549
  </style>
114
550
  </head>
@@ -116,72 +552,131 @@
116
552
  <div class="app">
117
553
  <main class="stage">
118
554
  <canvas id="graph"></canvas>
555
+
556
+ <section class="search-shell">
557
+ <div class="search-head">
558
+ <div class="search-title">
559
+ <strong>Explore the graph</strong>
560
+ <span>Search topics, files, conversations, decisions, and tasks.</span>
561
+ </div>
562
+ <div class="search-count" id="search-count">Ready</div>
563
+ </div>
564
+ <div class="search-input-wrap">
565
+ <div class="search-input-row">
566
+ <input id="search" class="search-input" placeholder="Search by topic, file, or conversation..." autocomplete="off">
567
+ <button id="clear-search-btn" class="icon-btn" title="Clear search">×</button>
568
+ </div>
569
+ </div>
570
+ <div id="search-results" class="search-results">
571
+ <p class="search-empty">검색 결과는 여기에 표시됩니다. 키워드를 입력하면 서버 검색 결과를 불러오고, 항목을 누르면 해당 노드로 바로 이동합니다.</p>
572
+ </div>
573
+ </section>
574
+
119
575
  <div class="toolbar">
120
- <input id="search" placeholder="Search nodes…" autocomplete="off">
121
- <button class="tb-btn" id="fit-btn">Fit</button>
576
+ <button class="tb-btn" id="fit-btn">Fit view</button>
122
577
  <button class="tb-btn" id="refresh-btn">Refresh</button>
123
- <button class="tb-btn" id="back-btn">← Chat</button>
578
+ <button class="tb-btn" id="back-btn">Back to chat</button>
124
579
  </div>
125
580
  </main>
581
+
126
582
  <aside>
127
583
  <div class="sidebar-head">
128
- <h1>Knowledge Graph</h1>
584
+ <div class="eyebrow">Data Graph</div>
585
+ <h1>Knowledge topology</h1>
586
+ <p class="sidebar-sub">주제의 크기는 중요도 기반으로, 선의 굵기와 색은 관계 종류와 강도를 반영합니다.</p>
129
587
  <div class="stats-row">
130
- <div class="stat"><strong id="node-count">—</strong><span>nodes</span></div>
131
- <div class="stat"><strong id="edge-count">—</strong><span>edges</span></div>
588
+ <div class="stat"><strong id="node-count">-</strong><span>Nodes</span></div>
589
+ <div class="stat"><strong id="edge-count">-</strong><span>Edges</span></div>
132
590
  </div>
133
591
  </div>
134
- <div class="type-filters" id="type-filters"></div>
592
+
593
+ <div class="section">
594
+ <div class="section-label">Relationship legend</div>
595
+ <div id="edge-legend" class="legend-grid"></div>
596
+ </div>
597
+
598
+ <div class="section">
599
+ <div class="section-label">Node types</div>
600
+ <div id="type-filters" class="filter-grid"></div>
601
+ </div>
602
+
135
603
  <div class="detail-wrap">
136
604
  <div id="detail">
137
- <p class="empty-hint">채팅과 파일이 쌓이면 여기에 연결 관계가 나타납니다.<br>노드를 클릭하면 원문 요약과 메타데이터를 수 있습니다.</p>
605
+ <p class="empty-hint">노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.</p>
138
606
  </div>
139
607
  </div>
140
608
  </aside>
141
609
  </div>
610
+
142
611
  <div id="tooltip"></div>
143
612
 
144
613
  <script>
145
614
  const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
146
615
 
147
616
  const TYPE_CONFIG = {
148
- Conversation: { color: '#8fa8bb', label: 'Conversation' },
149
- Message: { color: '#dce4ee', label: 'Message' },
150
- AIResponse: { color: '#ea7fa4', label: 'AI Response' },
151
- File: { color: '#73a7ff', label: 'File' },
152
- Topic: { color: '#f2b36d', label: 'Topic' },
153
- Person: { color: '#54d6a5', label: 'Person' },
154
- Page: { color: '#89c2ff', label: 'Page' },
155
- Slide: { color: '#9bb6ff', label: 'Slide' },
156
- Sheet: { color: '#78d4c8', label: 'Sheet' },
157
- Image: { color: '#ffd166', label: 'Image' },
158
- Decision: { color: '#c4f06f', label: 'Decision' },
159
- Task: { color: '#ff9f7a', label: 'Task' },
160
- ClearEvent: { color: '#bca7ff', label: 'Clear Event' },
617
+ Conversation: { color: '#f7f7f2', label: 'Conversation' },
618
+ Message: { color: '#f7f7f2', label: 'Message' },
619
+ AIResponse: { color: '#a77cff', label: 'AI Response' },
620
+ File: { color: '#7db7ff', label: 'File' },
621
+ Topic: { color: '#a77cff', label: 'Topic' },
622
+ Person: { color: '#20b8aa', label: 'Person' },
623
+ Page: { color: '#f7f7f2', label: 'Page' },
624
+ Slide: { color: '#8fa3ff', label: 'Slide' },
625
+ Sheet: { color: '#20b8aa', label: 'Sheet' },
626
+ Image: { color: '#f1c86d', label: 'Image' },
627
+ Decision: { color: '#f1c86d', label: 'Decision' },
628
+ Task: { color: '#ff7db3', label: 'Task' },
629
+ ClearEvent: { color: '#7a6ba8', label: 'Clear Event' },
630
+ Event: { color: '#7a6ba8', label: 'Event' },
631
+ };
632
+
633
+ const EDGE_CONFIG = {
634
+ contains: { color: '#7186c8', label: 'Contains', width: 1.3 },
635
+ authored: { color: '#20b8aa', label: 'Authored', width: 1.5 },
636
+ uploaded: { color: '#7db7ff', label: 'Uploaded', width: 1.5 },
637
+ has_event: { color: '#7a6ba8', label: 'Event', width: 1.2 },
638
+ triggered: { color: '#a77cff', label: 'Triggered', width: 1.2, dash: [5, 4] },
639
+ mentions: { color: '#aebcff', label: 'Mentions', width: 1.55 },
640
+ discusses: { color: '#c9b7ff', label: 'Discusses', width: 1.75 },
641
+ implies: { color: '#ff7db3', label: 'Implies', width: 1.55 },
642
+ based_on: { color: '#a77cff', label: 'Based on', width: 1.4, dash: [8, 4] },
643
+ contains_signal: { color: '#f1c86d', label: 'Signal', width: 1.6 },
644
+ has_page: { color: '#7186c8', label: 'Page', width: 1.25 },
645
+ has_slide: { color: '#8fa3ff', label: 'Slide', width: 1.3 },
646
+ has_sheet: { color: '#20b8aa', label: 'Sheet', width: 1.3 },
647
+ contains_image: { color: '#f1c86d', label: 'Image', width: 1.35 },
648
+ has_chunk: { color: '#4e566f', label: 'Chunk', width: 0.9, dash: [2, 5] },
161
649
  };
162
650
 
163
651
  const canvas = document.getElementById('graph');
164
652
  const ctx = canvas.getContext('2d');
165
653
  const detail = document.getElementById('detail');
166
- const search = document.getElementById('search');
167
654
  const tooltip = document.getElementById('tooltip');
655
+ const searchInput = document.getElementById('search');
656
+ const searchResultsEl = document.getElementById('search-results');
657
+ const searchCountEl = document.getElementById('search-count');
168
658
 
169
659
  let rawGraph = { nodes: [], edges: [] };
170
- let graph = { nodes: [], edges: [] };
660
+ let graph = { nodes: [], edges: [] };
171
661
  let hiddenTypes = new Set();
172
662
  let selected = null;
173
- let hovered = null;
663
+ let hovered = null;
174
664
  let dragging = null;
175
- let panning = null;
665
+ let panning = null;
176
666
  let cam = { scale: 1, tx: 0, ty: 0 };
177
667
  let animFrameId = null;
178
- let width = 0, height = 0;
668
+ let width = 0;
669
+ let height = 0;
670
+ let searchResults = [];
671
+ let searchResultIds = new Set();
672
+ let searchAbortController = null;
673
+ let searchDebounceId = null;
179
674
 
180
- // ── Auth ─────────────────────────────────────────────────────────────────
181
675
  function authHeaders() {
182
- const t = localStorage.getItem('ltcai_session_token') || '';
183
- return t ? { Authorization: `Bearer ${t}` } : {};
676
+ const token = localStorage.getItem('ltcai_session_token') || '';
677
+ return token ? { Authorization: `Bearer ${token}` } : {};
184
678
  }
679
+
185
680
  function apiFetch(path, opts = {}) {
186
681
  return fetch(`${API_BASE}${path}`, {
187
682
  credentials: 'include',
@@ -190,156 +685,307 @@
190
685
  });
191
686
  }
192
687
 
193
- // ── Color ─────────────────────────────────────────────────────────────────
688
+ function clamp(value, min, max) {
689
+ return Math.max(min, Math.min(max, value));
690
+ }
691
+
692
+ function escapeHtml(text) {
693
+ return String(text || '')
694
+ .replaceAll('&', '&amp;')
695
+ .replaceAll('<', '&lt;')
696
+ .replaceAll('>', '&gt;')
697
+ .replaceAll('"', '&quot;')
698
+ .replaceAll("'", '&#39;');
699
+ }
700
+
194
701
  function nodeColor(type) {
195
702
  return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
196
703
  }
197
704
 
198
- // ── Degree & layout ───────────────────────────────────────────────────────
199
- function computeDegrees() {
200
- const deg = {};
201
- rawGraph.edges.forEach(e => {
202
- deg[e.from] = (deg[e.from] || 0) + 1;
203
- deg[e.to] = (deg[e.to] || 0) + 1;
705
+ function edgeStyle(type) {
706
+ return EDGE_CONFIG[type] || { color: '#7f8f9d', label: type, width: 1.3 };
707
+ }
708
+
709
+ function typeLabel(type) {
710
+ return (TYPE_CONFIG[type] || {}).label || type;
711
+ }
712
+
713
+ function formatMetric(value, digits = 2) {
714
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
715
+ const num = Number(value);
716
+ if (Math.abs(num) >= 1000) return num.toLocaleString();
717
+ return Number.isInteger(num) ? String(num) : num.toFixed(digits);
718
+ }
719
+
720
+ function formatUpdatedAt(updatedAt) {
721
+ if (!updatedAt) return '';
722
+ const stamp = new Date(updatedAt);
723
+ if (Number.isNaN(stamp.getTime())) return '';
724
+ const diffMs = Date.now() - stamp.getTime();
725
+ const diffDays = Math.floor(diffMs / 86400000);
726
+ if (diffDays <= 0) return 'today';
727
+ if (diffDays === 1) return '1 day ago';
728
+ if (diffDays < 30) return `${diffDays} days ago`;
729
+ const diffMonths = Math.floor(diffDays / 30);
730
+ if (diffMonths < 12) return `${diffMonths} mo ago`;
731
+ const diffYears = Math.floor(diffMonths / 12);
732
+ return `${diffYears} yr ago`;
733
+ }
734
+
735
+ function updateStats() {
736
+ document.getElementById('node-count').textContent = rawGraph.nodes.length.toLocaleString();
737
+ document.getElementById('edge-count').textContent = rawGraph.edges.length.toLocaleString();
738
+ }
739
+
740
+ function computeVisuals() {
741
+ const degreeMap = {};
742
+ rawGraph.edges.forEach(edge => {
743
+ degreeMap[edge.from] = (degreeMap[edge.from] || 0) + 1;
744
+ degreeMap[edge.to] = (degreeMap[edge.to] || 0) + 1;
745
+ });
746
+
747
+ rawGraph.nodes.forEach(node => {
748
+ const metrics = ((node.metadata || {}).graph_metrics) || {};
749
+ const importanceNorm = clamp(
750
+ Number.isFinite(Number(node.importance_norm))
751
+ ? Number(node.importance_norm)
752
+ : Number(metrics.importance_norm || 0),
753
+ 0,
754
+ 1
755
+ );
756
+ node.degree = degreeMap[node.id] || Number(metrics.degree || 0) || 0;
757
+ node.importance_norm = importanceNorm;
758
+ node.importance = Number.isFinite(Number(node.importance))
759
+ ? Number(node.importance)
760
+ : Number(metrics.importance_raw || 0);
761
+
762
+ let radius = 6;
763
+ if (node.type === 'Topic') {
764
+ radius = 10 + importanceNorm * 18 + Math.sqrt(node.degree) * 0.8;
765
+ } else if (node.type === 'Conversation') {
766
+ radius = 8 + importanceNorm * 11 + Math.sqrt(node.degree) * 0.55;
767
+ } else if (node.type === 'File') {
768
+ radius = 7 + importanceNorm * 9 + Math.sqrt(node.degree) * 0.5;
769
+ } else if (node.type === 'Decision' || node.type === 'Task') {
770
+ radius = 7 + importanceNorm * 8 + Math.sqrt(node.degree) * 0.45;
771
+ } else {
772
+ radius = 5 + importanceNorm * 7 + Math.sqrt(node.degree) * 0.35;
773
+ }
774
+ const maxRadius = node.type === 'Topic' ? 34 : 24;
775
+ node.r = clamp(radius, node.type === 'Topic' ? 9 : 5, maxRadius);
776
+ });
777
+ }
778
+
779
+ function buildTypeCounts() {
780
+ const counts = {};
781
+ rawGraph.nodes.forEach(node => {
782
+ counts[node.type] = (counts[node.type] || 0) + 1;
204
783
  });
205
- rawGraph.nodes.forEach(n => {
206
- n.degree = deg[n.id] || 0;
207
- const base = 4 + Math.sqrt(n.degree) * 2.8;
208
- if (n.type === 'Conversation') n.r = Math.max(base, 10);
209
- else if (n.type === 'File') n.r = Math.max(base, 8);
210
- else if (n.type === 'Topic') n.r = Math.max(base, 5);
211
- else n.r = Math.max(base, 4);
212
- n.r = Math.min(n.r, 22);
784
+ return counts;
785
+ }
786
+
787
+ function buildEdgeCounts() {
788
+ const counts = {};
789
+ rawGraph.edges.forEach(edge => {
790
+ counts[edge.type] = (counts[edge.type] || 0) + 1;
213
791
  });
792
+ return counts;
214
793
  }
215
794
 
216
795
  function applyFilter() {
217
- graph.nodes = rawGraph.nodes.filter(n => !hiddenTypes.has(n.type));
218
- const nodeSet = new Set(graph.nodes.map(n => n.id));
219
- const byId = Object.fromEntries(rawGraph.nodes.map(n => [n.id, n]));
796
+ graph.nodes = rawGraph.nodes.filter(node => !hiddenTypes.has(node.type));
797
+ const nodeSet = new Set(graph.nodes.map(node => node.id));
798
+ const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
220
799
  graph.edges = rawGraph.edges
221
- .filter(e => nodeSet.has(e.from) && nodeSet.has(e.to))
222
- .map(e => ({ ...e, source: byId[e.from], target: byId[e.to] }));
800
+ .filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
801
+ .map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
223
802
  }
224
803
 
225
804
  function seedLayout() {
226
- rawGraph.nodes.forEach((n, i) => {
227
- if (n.x === undefined) {
228
- const a = (i / Math.max(1, rawGraph.nodes.length)) * Math.PI * 2;
229
- n.x = width / 2 + Math.cos(a) * Math.min(width, height) * 0.32;
230
- n.y = height / 2 + Math.sin(a) * Math.min(width, height) * 0.32;
805
+ rawGraph.nodes.forEach((node, index) => {
806
+ if (node.x === undefined || node.y === undefined) {
807
+ const angle = (index / Math.max(1, rawGraph.nodes.length)) * Math.PI * 2;
808
+ const ring = Math.min(width, height) * (node.type === 'Topic' ? 0.22 : 0.32);
809
+ node.x = width / 2 + Math.cos(angle) * ring;
810
+ node.y = height / 2 + Math.sin(angle) * ring;
231
811
  }
232
- n.vx = n.vx || 0;
233
- n.vy = n.vy || 0;
812
+ node.vx = node.vx || 0;
813
+ node.vy = node.vy || 0;
234
814
  });
235
815
  }
236
816
 
237
- // ── Load ──────────────────────────────────────────────────────────────────
238
- async function loadGraph() {
239
- document.getElementById('node-count').textContent = '…';
240
- document.getElementById('edge-count').textContent = '…';
817
+ function mergeGraphData(extraNodes, extraEdges) {
818
+ const nodeMap = new Map(rawGraph.nodes.map(node => [node.id, node]));
819
+ extraNodes.forEach(node => {
820
+ const prev = nodeMap.get(node.id) || {};
821
+ nodeMap.set(node.id, {
822
+ ...prev,
823
+ ...node,
824
+ metadata: { ...(prev.metadata || {}), ...(node.metadata || {}) },
825
+ });
826
+ });
827
+ rawGraph.nodes = [...nodeMap.values()];
828
+
829
+ const edgeMap = new Map(rawGraph.edges.map(edge => [edge.id || `${edge.from}|${edge.type}|${edge.to}`, edge]));
830
+ extraEdges.forEach(edge => {
831
+ const key = edge.id || `${edge.from}|${edge.type}|${edge.to}`;
832
+ edgeMap.set(key, edge);
833
+ });
834
+ rawGraph.edges = [...edgeMap.values()];
835
+
836
+ computeVisuals();
837
+ seedLayout();
838
+ applyFilter();
839
+ updateStats();
840
+ renderTypeFilters(buildTypeCounts());
841
+ renderEdgeLegend(buildEdgeCounts());
842
+ }
241
843
 
242
- const [gRes, sRes] = await Promise.all([
844
+ async function loadGraph() {
845
+ updateStats();
846
+ const [graphRes, statsRes] = await Promise.all([
243
847
  apiFetch('/knowledge-graph/graph?limit=600'),
244
848
  apiFetch('/knowledge-graph/stats'),
245
849
  ]);
246
- if (gRes.status === 401) { window.location.href = '/account'; return; }
247
- if (!gRes.ok) throw new Error(`Graph API failed (${gRes.status})`);
248
-
249
- rawGraph = await gRes.json();
250
- rawGraph.nodes = Array.isArray(rawGraph.nodes) ? rawGraph.nodes : [];
251
- rawGraph.edges = Array.isArray(rawGraph.edges) ? rawGraph.edges : [];
252
- const stats = sRes.ok ? await sRes.json() : {};
850
+ if (graphRes.status === 401) {
851
+ window.location.href = '/account';
852
+ return;
853
+ }
854
+ if (!graphRes.ok) throw new Error(`Graph API failed (${graphRes.status})`);
253
855
 
254
- computeDegrees();
856
+ const graphData = await graphRes.json();
857
+ const stats = statsRes.ok ? await statsRes.json() : {};
858
+ rawGraph = {
859
+ nodes: Array.isArray(graphData.nodes) ? graphData.nodes : [],
860
+ edges: Array.isArray(graphData.edges) ? graphData.edges : [],
861
+ };
862
+ computeVisuals();
255
863
  seedLayout();
256
864
  applyFilter();
257
-
258
- document.getElementById('node-count').textContent = rawGraph.nodes.length;
259
- document.getElementById('edge-count').textContent = rawGraph.edges.length;
260
- renderTypeFilters(stats.nodes || {});
261
- showDetail(selected && rawGraph.nodes.find(n => n.id === selected.id) || graph.nodes[0] || null);
865
+ updateStats();
866
+ renderTypeFilters(stats.nodes || buildTypeCounts());
867
+ renderEdgeLegend(stats.edges || {});
868
+ showDetail(selected && rawGraph.nodes.find(node => node.id === selected.id) || graph.nodes[0] || null);
262
869
  cam = { scale: 1, tx: 0, ty: 0 };
870
+ fitToScreen();
263
871
  wakeUp();
264
872
  }
265
873
 
266
- // ── Type filters ──────────────────────────────────────────────────────────
267
874
  function renderTypeFilters(typeCounts) {
268
- const presentTypes = [...new Set(rawGraph.nodes.map(n => n.type))];
269
- const ordered = [...Object.keys(TYPE_CONFIG), ...presentTypes.filter(t => !TYPE_CONFIG[t])].filter(t => presentTypes.includes(t));
875
+ const presentTypes = [...new Set(rawGraph.nodes.map(node => node.type))];
876
+ const ordered = [...Object.keys(TYPE_CONFIG), ...presentTypes.filter(type => !TYPE_CONFIG[type])]
877
+ .filter(type => presentTypes.includes(type));
270
878
  const container = document.getElementById('type-filters');
271
- if (!ordered.length) { container.innerHTML = ''; return; }
272
- const items = ordered.map(t => {
273
- const cfg = TYPE_CONFIG[t] || {};
274
- const col = cfg.color || '#8fa8bb';
275
- const cnt = typeCounts[t] ?? rawGraph.nodes.filter(n => n.type === t).length;
276
- const chk = !hiddenTypes.has(t) ? 'checked' : '';
277
- return `<label class="filter-item">
278
- <input type="checkbox" ${chk} onchange="toggleType('${t}',this.checked)">
279
- <span class="dot" style="background:${col}"></span>
280
- <span class="filter-name">${cfg.label || t}</span>
281
- <span class="filter-count">${cnt}</span>
282
- </label>`;
879
+ if (!ordered.length) {
880
+ container.innerHTML = '<div class="empty-hint">No node types yet.</div>';
881
+ return;
882
+ }
883
+ container.innerHTML = ordered.map(type => {
884
+ const checked = hiddenTypes.has(type) ? '' : 'checked';
885
+ return `
886
+ <label class="filter-item">
887
+ <input type="checkbox" ${checked} onchange="toggleType('${type}', this.checked)">
888
+ <span class="dot" style="background:${nodeColor(type)}"></span>
889
+ <span class="filter-name">${escapeHtml(typeLabel(type))}</span>
890
+ <span class="filter-count">${typeCounts[type] || 0}</span>
891
+ </label>
892
+ `;
893
+ }).join('');
894
+ }
895
+
896
+ function renderEdgeLegend(edgeCounts) {
897
+ const presentEdgeTypes = [...new Set(rawGraph.edges.map(edge => edge.type))];
898
+ const ordered = [...Object.keys(EDGE_CONFIG), ...presentEdgeTypes.filter(type => !EDGE_CONFIG[type])]
899
+ .filter(type => presentEdgeTypes.includes(type));
900
+ const container = document.getElementById('edge-legend');
901
+ if (!ordered.length) {
902
+ container.innerHTML = '<div class="empty-hint">No relationships yet.</div>';
903
+ return;
904
+ }
905
+ container.innerHTML = ordered.map(type => {
906
+ const style = edgeStyle(type);
907
+ return `
908
+ <div class="legend-item">
909
+ <span class="legend-line" style="border-top-color:${style.color}; border-top-width:${Math.max(2, style.width)}px;"></span>
910
+ <span class="legend-name">${escapeHtml(style.label || type)}</span>
911
+ <span class="legend-meta">${edgeCounts[type] || 0}</span>
912
+ </div>
913
+ `;
283
914
  }).join('');
284
- container.innerHTML = `<div class="filter-section-label">Node types</div><div class="filter-grid">${items}</div>`;
285
915
  }
286
916
 
287
917
  function toggleType(type, visible) {
288
- if (visible) hiddenTypes.delete(type); else hiddenTypes.add(type);
918
+ if (visible) hiddenTypes.delete(type);
919
+ else hiddenTypes.add(type);
289
920
  applyFilter();
921
+ if (selected && hiddenTypes.has(selected.type)) showDetail(null);
290
922
  wakeUp();
291
923
  }
924
+ window.toggleType = toggleType;
292
925
 
293
- // ── Physics ───────────────────────────────────────────────────────────────
294
926
  function step() {
295
927
  const nodes = graph.nodes;
296
928
  const edges = graph.edges;
929
+ const centerPull = selected ? 0.00035 : 0.00055;
297
930
 
298
931
  for (let i = 0; i < nodes.length; i++) {
299
932
  for (let j = i + 1; j < nodes.length; j++) {
300
- const a = nodes[i], b = nodes[j];
301
- const dx = a.x - b.x, dy = a.y - b.y;
302
- const d2 = Math.max(100, dx * dx + dy * dy);
303
- const f = 2200 / d2;
304
- a.vx += dx * f; a.vy += dy * f;
305
- b.vx -= dx * f; b.vy -= dy * f;
933
+ const a = nodes[i];
934
+ const b = nodes[j];
935
+ const dx = a.x - b.x;
936
+ const dy = a.y - b.y;
937
+ const d2 = Math.max(120, dx * dx + dy * dy);
938
+ const strength = (a.type === 'Topic' || b.type === 'Topic') ? 2900 : 2100;
939
+ const force = strength / d2;
940
+ a.vx += dx * force;
941
+ a.vy += dy * force;
942
+ b.vx -= dx * force;
943
+ b.vy -= dy * force;
306
944
  }
307
945
  }
308
946
 
309
- edges.forEach(e => {
310
- if (!e.source || !e.target) return;
311
- const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
947
+ edges.forEach(edge => {
948
+ if (!edge.source || !edge.target) return;
949
+ const dx = edge.target.x - edge.source.x;
950
+ const dy = edge.target.y - edge.source.y;
312
951
  const dist = Math.max(1, Math.hypot(dx, dy));
313
- const target = 130;
314
- const f = (dist - target) * 0.005;
315
- e.source.vx += dx / dist * f; e.source.vy += dy / dist * f;
316
- e.target.vx -= dx / dist * f; e.target.vy -= dy / dist * f;
952
+ const targetDistance = edge.type === 'mentions' || edge.type === 'discusses'
953
+ ? 118
954
+ : edge.type === 'contains'
955
+ ? 138
956
+ : 132;
957
+ const force = (dist - targetDistance) * (0.0038 + Math.min(0.003, (edge.weight || 1) * 0.0015));
958
+ edge.source.vx += (dx / dist) * force;
959
+ edge.source.vy += (dy / dist) * force;
960
+ edge.target.vx -= (dx / dist) * force;
961
+ edge.target.vy -= (dy / dist) * force;
317
962
  });
318
963
 
319
- let ke = 0;
320
- nodes.forEach(n => {
321
- if (n === dragging) return;
322
- n.vx += (width / 2 - n.x) * 0.0006;
323
- n.vy += (height / 2 - n.y) * 0.0006;
324
- n.vx *= 0.85; n.vy *= 0.85;
325
- n.x += n.vx; n.y += n.vy;
326
- ke += n.vx * n.vx + n.vy * n.vy;
964
+ let kineticEnergy = 0;
965
+ nodes.forEach(node => {
966
+ if (node === dragging) return;
967
+ node.vx += (width / 2 - node.x) * centerPull;
968
+ node.vy += (height / 2 - node.y) * centerPull;
969
+ node.vx *= 0.84;
970
+ node.vy *= 0.84;
971
+ node.x += node.vx;
972
+ node.y += node.vy;
973
+ kineticEnergy += node.vx * node.vx + node.vy * node.vy;
327
974
  });
328
- return ke;
975
+ return kineticEnergy;
329
976
  }
330
977
 
331
978
  function wakeUp() {
332
979
  if (!animFrameId) animFrameId = requestAnimationFrame(draw);
333
980
  }
334
981
 
335
- // ── Draw ──────────────────────────────────────────────────────────────────
336
982
  const nbCache = new Map();
337
983
  function neighborIds(node) {
338
984
  if (nbCache.has(node.id)) return nbCache.get(node.id);
339
985
  const ids = new Set([node.id]);
340
- graph.edges.forEach(e => {
341
- if (e.from === node.id) ids.add(e.to);
342
- if (e.to === node.id) ids.add(e.from);
986
+ graph.edges.forEach(edge => {
987
+ if (edge.from === node.id) ids.add(edge.to);
988
+ if (edge.to === node.id) ids.add(edge.from);
343
989
  });
344
990
  nbCache.set(node.id, ids);
345
991
  return ids;
@@ -347,7 +993,7 @@
347
993
 
348
994
  function draw() {
349
995
  animFrameId = null;
350
- const ke = step();
996
+ const kineticEnergy = step();
351
997
  nbCache.clear();
352
998
 
353
999
  ctx.clearRect(0, 0, width, height);
@@ -356,256 +1002,504 @@
356
1002
  ctx.scale(cam.scale, cam.scale);
357
1003
 
358
1004
  const active = hovered || selected;
359
- const nbIds = active ? neighborIds(active) : null;
360
- const q = search.value.trim().toLowerCase();
361
-
362
- // Edges
363
- graph.edges.forEach(e => {
364
- if (!e.source || !e.target) return;
365
- const hi = nbIds && nbIds.has(e.from) && nbIds.has(e.to);
366
- const a = nbIds ? (hi ? 0.7 : 0.06) : 0.22;
367
- ctx.strokeStyle = (e.type === 'mentions' || e.type === 'discusses')
368
- ? `rgba(242,179,109,${a})` : `rgba(155,167,180,${a})`;
369
- ctx.lineWidth = 1 / cam.scale;
1005
+ const neighborSet = active ? neighborIds(active) : null;
1006
+
1007
+ graph.edges.forEach(edge => {
1008
+ if (!edge.source || !edge.target) return;
1009
+ const style = edgeStyle(edge.type);
1010
+ const isNeighborEdge = neighborSet && neighborSet.has(edge.from) && neighborSet.has(edge.to);
1011
+ const baseAlpha = neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34;
1012
+ const widthBoost = isNeighborEdge ? 0.5 : 0;
1013
+ ctx.save();
1014
+ ctx.globalAlpha = baseAlpha;
1015
+ ctx.strokeStyle = style.color;
1016
+ ctx.lineWidth = (style.width + Math.min(3.4, (edge.weight || 1) * 1.1) + widthBoost) / cam.scale;
1017
+ ctx.setLineDash(style.dash || []);
370
1018
  ctx.beginPath();
371
- ctx.moveTo(e.source.x, e.source.y);
372
- ctx.lineTo(e.target.x, e.target.y);
1019
+ ctx.moveTo(edge.source.x, edge.source.y);
1020
+ ctx.lineTo(edge.target.x, edge.target.y);
373
1021
  ctx.stroke();
1022
+ ctx.restore();
374
1023
  });
375
1024
 
376
- // Nodes
377
- graph.nodes.forEach(n => {
378
- const isNb = nbIds ? nbIds.has(n.id) : true;
379
- const hit = q && `${n.title} ${n.type} ${n.summary || ''}`.toLowerCase().includes(q);
380
- const isSel = n === selected;
381
- const isHov = n === hovered;
382
- const alpha = nbIds ? (isNb ? 1 : 0.12) : 1;
383
- const r = n.r + (isSel ? 4 : isHov ? 2 : hit ? 2 : 0);
1025
+ graph.nodes.forEach(node => {
1026
+ const isNeighbor = neighborSet ? neighborSet.has(node.id) : true;
1027
+ const isSearchHit = searchResultIds.has(node.id);
1028
+ const isSelected = node === selected;
1029
+ const isHovered = node === hovered;
1030
+ const alpha = neighborSet ? (isNeighbor ? 1 : 0.12) : 1;
1031
+ const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isSearchHit ? 2.6 : 0);
384
1032
 
385
1033
  ctx.globalAlpha = alpha;
1034
+
1035
+ if (node.type === 'Topic') {
1036
+ const haloRadius = radius + 6 + node.importance_norm * 8;
1037
+ const halo = ctx.createRadialGradient(node.x, node.y, radius * 0.4, node.x, node.y, haloRadius);
1038
+ halo.addColorStop(0, `${nodeColor(node.type)}30`);
1039
+ halo.addColorStop(1, `${nodeColor(node.type)}00`);
1040
+ ctx.fillStyle = halo;
1041
+ ctx.beginPath();
1042
+ ctx.arc(node.x, node.y, haloRadius, 0, Math.PI * 2);
1043
+ ctx.fill();
1044
+ }
1045
+
1046
+ ctx.fillStyle = isSelected ? '#ffffff' : nodeColor(node.type);
386
1047
  ctx.beginPath();
387
- ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
388
- ctx.fillStyle = isSel ? '#ffffff' : nodeColor(n.type);
1048
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
389
1049
  ctx.fill();
390
1050
 
391
- if (isSel || isHov) {
392
- ctx.strokeStyle = isSel ? 'rgba(255,255,255,0.5)' : nodeColor(n.type);
393
- ctx.lineWidth = (isSel ? 2.5 : 1.5) / cam.scale;
1051
+ if (isSelected || isHovered || isSearchHit) {
1052
+ ctx.strokeStyle = isSelected ? '#ffffff' : nodeColor(node.type);
1053
+ ctx.lineWidth = (isSelected ? 2.8 : 1.8) / cam.scale;
394
1054
  ctx.beginPath();
395
- ctx.arc(n.x, n.y, r + 3.5, 0, Math.PI * 2);
1055
+ ctx.arc(node.x, node.y, radius + 3.5, 0, Math.PI * 2);
396
1056
  ctx.stroke();
397
1057
  }
398
1058
 
399
- const showLabel = hit || isSel || isHov
400
- || n.type === 'File' || n.type === 'Conversation'
401
- || (n.type === 'Topic' && cam.scale > 0.55)
402
- || (n.type === 'Person');
1059
+ const showLabel = isSelected || isHovered || isSearchHit
1060
+ || node.type === 'Conversation'
1061
+ || node.type === 'File'
1062
+ || (node.type === 'Topic' && (node.importance_norm > 0.45 || cam.scale > 0.52))
1063
+ || (node.type === 'Decision' || node.type === 'Task');
403
1064
  if (showLabel) {
404
- ctx.fillStyle = alpha < 0.5 ? 'rgba(220,228,238,0.25)' : '#dce4ee';
405
- ctx.font = `${Math.max(9, 11 / cam.scale)}px system-ui`;
406
- ctx.fillText(n.title.slice(0, 36), n.x + r + 5 / cam.scale, n.y + 4 / cam.scale);
1065
+ ctx.fillStyle = alpha < 0.5 ? 'rgba(237,244,251,0.3)' : '#edf4fb';
1066
+ ctx.font = `${Math.max(9, 11.5 / cam.scale)}px system-ui`;
1067
+ ctx.fillText(node.title.slice(0, 40), node.x + radius + 6 / cam.scale, node.y + 4 / cam.scale);
407
1068
  }
1069
+
408
1070
  ctx.globalAlpha = 1;
409
1071
  });
410
1072
 
411
1073
  ctx.restore();
412
- if (ke > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
1074
+ if (kineticEnergy > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
413
1075
  }
414
1076
 
415
- // ── Coordinates ───────────────────────────────────────────────────────────
416
- function toWorld(cx, cy) {
417
- return { x: (cx - cam.tx) / cam.scale, y: (cy - cam.ty) / cam.scale };
1077
+ function toWorld(canvasX, canvasY) {
1078
+ return { x: (canvasX - cam.tx) / cam.scale, y: (canvasY - cam.ty) / cam.scale };
418
1079
  }
419
- function nodeAt(cx, cy) {
420
- const { x, y } = toWorld(cx, cy);
421
- let best = null, bestD = Infinity;
422
- graph.nodes.forEach(n => {
423
- const d = Math.hypot(n.x - x, n.y - y);
424
- if (d < (n.r + 9) / cam.scale && d < bestD) { best = n; bestD = d; }
1080
+
1081
+ function nodeAt(canvasX, canvasY) {
1082
+ const { x, y } = toWorld(canvasX, canvasY);
1083
+ let best = null;
1084
+ let bestDistance = Infinity;
1085
+ graph.nodes.forEach(node => {
1086
+ const distance = Math.hypot(node.x - x, node.y - y);
1087
+ if (distance < (node.r + 10) / cam.scale && distance < bestDistance) {
1088
+ best = node;
1089
+ bestDistance = distance;
1090
+ }
425
1091
  });
426
1092
  return best;
427
1093
  }
428
1094
 
429
- // ── Fit to screen ─────────────────────────────────────────────────────────
430
1095
  function fitToScreen() {
431
1096
  if (!graph.nodes.length) return;
432
1097
  let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
433
- graph.nodes.forEach(n => {
434
- x0 = Math.min(x0, n.x - n.r); x1 = Math.max(x1, n.x + n.r);
435
- y0 = Math.min(y0, n.y - n.r); y1 = Math.max(y1, n.y + n.r);
1098
+ graph.nodes.forEach(node => {
1099
+ x0 = Math.min(x0, node.x - node.r);
1100
+ x1 = Math.max(x1, node.x + node.r);
1101
+ y0 = Math.min(y0, node.y - node.r);
1102
+ y1 = Math.max(y1, node.y + node.r);
436
1103
  });
437
- const margin = 52;
438
- const s = Math.min(3, Math.min(
439
- (width - margin * 2) / Math.max(1, x1 - x0),
440
- (height - margin * 2) / Math.max(1, y1 - y0),
441
- ));
442
- cam.scale = s;
443
- cam.tx = (width - (x0 + x1) * s) / 2;
444
- cam.ty = (height - (y0 + y1) * s) / 2;
1104
+ const margin = 56;
1105
+ const scale = Math.min(
1106
+ 2.8,
1107
+ Math.min(
1108
+ (width - margin * 2) / Math.max(1, x1 - x0),
1109
+ (height - margin * 2) / Math.max(1, y1 - y0)
1110
+ )
1111
+ );
1112
+ cam.scale = scale;
1113
+ cam.tx = (width - (x0 + x1) * scale) / 2;
1114
+ cam.ty = (height - (y0 + y1) * scale) / 2;
445
1115
  wakeUp();
446
1116
  }
447
1117
 
448
- // ── Detail panel ──────────────────────────────────────────────────────────
1118
+ function centerOnNode(node, targetScale = cam.scale) {
1119
+ cam.scale = clamp(targetScale, 0.12, 4.5);
1120
+ cam.tx = width / 2 - node.x * cam.scale;
1121
+ cam.ty = height / 2 - node.y * cam.scale;
1122
+ wakeUp();
1123
+ }
1124
+
1125
+ function metricCards(node) {
1126
+ const metrics = ((node.metadata || {}).graph_metrics) || {};
1127
+ const cards = [
1128
+ { value: formatMetric(metrics.importance_norm ? metrics.importance_norm * 100 : 0, 0), label: 'Importance %' },
1129
+ { value: formatMetric(metrics.degree || node.degree || 0, 0), label: 'Connections' },
1130
+ ];
1131
+ if (node.type === 'Topic') {
1132
+ cards.push({ value: formatMetric(metrics.mention_count || 0, 0), label: 'Mentions' });
1133
+ cards.push({ value: formatMetric(metrics.conversation_count || 0, 0), label: 'Conversations' });
1134
+ } else {
1135
+ cards.push({ value: formatMetric(metrics.recency_score || 0), label: 'Recency' });
1136
+ cards.push({ value: formatMetric(node.importance || metrics.importance_raw || 0), label: 'Raw score' });
1137
+ }
1138
+ return `<div class="metric-grid">${cards.map(card => `
1139
+ <div class="metric-card">
1140
+ <strong>${escapeHtml(card.value)}</strong>
1141
+ <span>${escapeHtml(card.label)}</span>
1142
+ </div>
1143
+ `).join('')}</div>`;
1144
+ }
1145
+
449
1146
  function showDetail(node) {
450
1147
  if (!node) {
451
- detail.innerHTML = '<p class="empty-hint">노드를 클릭하면 원문 요약과 메타데이터를 볼 수 있습니다.</p>';
1148
+ selected = null;
1149
+ detail.innerHTML = '<p class="empty-hint">노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.</p>';
1150
+ wakeUp();
452
1151
  return;
453
1152
  }
454
1153
  selected = node;
455
- const meta = node.metadata || {};
1154
+ const meta = node.metadata || {};
456
1155
  const convId = meta.conversation_id;
457
1156
  const jumpHtml = convId
458
- ? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">→ 채팅에서 열기</a>`
1157
+ ? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">Open in chat</a>`
459
1158
  : '';
460
- const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
1159
+ const metrics = metricCards(node);
1160
+ const updatedAt = formatUpdatedAt(node.updated_at);
1161
+ const source = meta.filename || meta.conversation_id || meta.source || '';
1162
+ const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
461
1163
  detail.innerHTML = `
462
- <div class="type-badge" style="background:${nodeColor(node.type)};color:#07100d">${node.type}</div>
463
- <div class="detail-title"></div>
464
- ${node.summary ? '<div class="detail-summary"></div>' : ''}
1164
+ <div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
1165
+ <div class="detail-title">${escapeHtml(node.title || node.id)}</div>
1166
+ ${node.summary ? `<div class="detail-summary">${escapeHtml(node.summary)}</div>` : ''}
465
1167
  ${jumpHtml}
466
- ${metaStr ? '<div class="detail-meta"></div>' : ''}
1168
+ ${metrics}
1169
+ <div class="detail-summary">
1170
+ ${source ? `<strong>source:</strong> ${escapeHtml(source)}<br>` : ''}
1171
+ ${updatedAt ? `<strong>updated:</strong> ${escapeHtml(updatedAt)}` : ''}
1172
+ </div>
1173
+ ${metadataStr ? `<div class="meta-block">${escapeHtml(metadataStr)}</div>` : ''}
467
1174
  `;
468
- detail.querySelector('.detail-title').textContent = node.title || node.id;
469
- if (node.summary) detail.querySelector('.detail-summary').textContent = node.summary;
470
- if (metaStr) detail.querySelector('.detail-meta').textContent = metaStr;
1175
+ wakeUp();
471
1176
  }
472
1177
 
473
- // ── Canvas resize ─────────────────────────────────────────────────────────
474
1178
  function resize() {
475
1179
  const rect = canvas.getBoundingClientRect();
476
- width = rect.width; height = rect.height;
1180
+ width = rect.width;
1181
+ height = rect.height;
477
1182
  const dpr = window.devicePixelRatio || 1;
478
- canvas.width = Math.floor(width * dpr);
1183
+ canvas.width = Math.floor(width * dpr);
479
1184
  canvas.height = Math.floor(height * dpr);
480
1185
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
481
1186
  }
482
1187
 
483
- // ── Mouse events ──────────────────────────────────────────────────────────
484
- canvas.addEventListener('mousedown', e => {
1188
+ function setSearchIdleState(message = 'Ready') {
1189
+ searchCountEl.textContent = message;
1190
+ }
1191
+
1192
+ function renderSearchResults() {
1193
+ if (!searchInput.value.trim()) {
1194
+ searchResultsEl.innerHTML = '<p class="search-empty">검색 결과는 여기에 표시됩니다. 키워드를 입력하면 서버 검색 결과를 불러오고, 항목을 누르면 해당 노드로 바로 이동합니다.</p>';
1195
+ return;
1196
+ }
1197
+ if (!searchResults.length) {
1198
+ searchResultsEl.innerHTML = '<p class="search-empty">일치하는 노드를 찾지 못했습니다. 더 구체적인 주제어, 파일명, 대화 제목으로 다시 시도해 보세요.</p>';
1199
+ return;
1200
+ }
1201
+ searchResultsEl.innerHTML = `
1202
+ <div class="search-list">
1203
+ ${searchResults.map(match => {
1204
+ const active = selected && selected.id === match.id ? 'active' : '';
1205
+ const source = (match.metadata || {}).filename || (match.metadata || {}).conversation_id || '';
1206
+ return `
1207
+ <button class="search-item ${active}" data-node-id="${escapeHtml(match.id)}">
1208
+ <div class="search-item-top">
1209
+ <span class="search-type" style="background:${nodeColor(match.type)}">${escapeHtml(typeLabel(match.type))}</span>
1210
+ <span class="search-item-title">${escapeHtml(match.title || match.id)}</span>
1211
+ </div>
1212
+ ${match.summary ? `<p class="search-item-summary">${escapeHtml(match.summary)}</p>` : ''}
1213
+ <div class="search-item-meta">
1214
+ ${source ? `<span>${escapeHtml(source)}</span>` : ''}
1215
+ ${match.updated_at ? `<span>${escapeHtml(formatUpdatedAt(match.updated_at))}</span>` : ''}
1216
+ </div>
1217
+ </button>
1218
+ `;
1219
+ }).join('')}
1220
+ </div>
1221
+ `;
1222
+ }
1223
+
1224
+ async function runSearch(query) {
1225
+ const trimmed = String(query || '').trim();
1226
+ if (!trimmed) {
1227
+ searchResults = [];
1228
+ searchResultIds = new Set();
1229
+ setSearchIdleState('Ready');
1230
+ renderSearchResults();
1231
+ wakeUp();
1232
+ return;
1233
+ }
1234
+
1235
+ if (searchAbortController) searchAbortController.abort();
1236
+ searchAbortController = new AbortController();
1237
+ searchCountEl.textContent = 'Searching...';
1238
+ searchResultsEl.innerHTML = '<p class="search-loading">Searching graph index...</p>';
1239
+
1240
+ try {
1241
+ const res = await apiFetch(`/knowledge-graph/search?q=${encodeURIComponent(trimmed)}&limit=12`, {
1242
+ signal: searchAbortController.signal,
1243
+ });
1244
+ if (!res.ok) throw new Error(`Search failed (${res.status})`);
1245
+ const data = await res.json();
1246
+ searchResults = Array.isArray(data.matches) ? data.matches : [];
1247
+ searchResultIds = new Set(searchResults.map(match => match.id));
1248
+ searchCountEl.textContent = `${searchResults.length} result${searchResults.length === 1 ? '' : 's'}`;
1249
+ renderSearchResults();
1250
+ wakeUp();
1251
+ } catch (error) {
1252
+ if (error.name === 'AbortError') return;
1253
+ searchResults = [];
1254
+ searchResultIds = new Set();
1255
+ searchCountEl.textContent = 'Error';
1256
+ searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
1257
+ wakeUp();
1258
+ }
1259
+ }
1260
+
1261
+ function scheduleSearch() {
1262
+ clearTimeout(searchDebounceId);
1263
+ searchDebounceId = setTimeout(() => runSearch(searchInput.value), 160);
1264
+ }
1265
+
1266
+ function clearSearch() {
1267
+ searchInput.value = '';
1268
+ searchResults = [];
1269
+ searchResultIds = new Set();
1270
+ setSearchIdleState('Ready');
1271
+ renderSearchResults();
1272
+ wakeUp();
1273
+ }
1274
+
1275
+ async function focusSearchResult(match) {
1276
+ let node = rawGraph.nodes.find(item => item.id === match.id);
1277
+ if (!node) {
1278
+ const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(match.id)}`);
1279
+ if (res.ok) {
1280
+ const payload = await res.json();
1281
+ mergeGraphData([
1282
+ {
1283
+ id: match.id,
1284
+ type: match.type,
1285
+ title: match.title,
1286
+ summary: match.summary,
1287
+ metadata: match.metadata,
1288
+ updated_at: match.updated_at,
1289
+ },
1290
+ ...((payload.neighbors || []).map(nodeItem => ({
1291
+ ...nodeItem,
1292
+ updated_at: nodeItem.updated_at,
1293
+ }))),
1294
+ ], payload.edges || []);
1295
+ node = rawGraph.nodes.find(item => item.id === match.id);
1296
+ }
1297
+ }
1298
+ if (!node) return;
1299
+ showDetail(node);
1300
+ centerOnNode(node, Math.max(cam.scale, node.type === 'Topic' ? 1.15 : 0.95));
1301
+ renderSearchResults();
1302
+ }
1303
+
1304
+ canvas.addEventListener('mousedown', event => {
485
1305
  const rect = canvas.getBoundingClientRect();
486
- const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
487
- const n = nodeAt(cx, cy);
488
- if (n) { dragging = n; showDetail(n); wakeUp(); }
489
- else { panning = { sx: e.clientX, sy: e.clientY, tx0: cam.tx, ty0: cam.ty }; canvas.classList.add('panning'); }
1306
+ const canvasX = event.clientX - rect.left;
1307
+ const canvasY = event.clientY - rect.top;
1308
+ const node = nodeAt(canvasX, canvasY);
1309
+ if (node) {
1310
+ dragging = node;
1311
+ showDetail(node);
1312
+ } else {
1313
+ panning = { sx: event.clientX, sy: event.clientY, tx0: cam.tx, ty0: cam.ty };
1314
+ canvas.classList.add('panning');
1315
+ }
1316
+ wakeUp();
490
1317
  });
491
1318
 
492
- canvas.addEventListener('mousemove', e => {
1319
+ canvas.addEventListener('mousemove', event => {
493
1320
  const rect = canvas.getBoundingClientRect();
494
- const n = nodeAt(e.clientX - rect.left, e.clientY - rect.top);
495
- if (n !== hovered) { hovered = n; wakeUp(); }
496
- canvas.style.cursor = panning ? 'grabbing' : (n ? 'pointer' : 'grab');
497
- if (n) {
1321
+ const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
1322
+ if (node !== hovered) {
1323
+ hovered = node;
1324
+ wakeUp();
1325
+ }
1326
+ canvas.style.cursor = panning ? 'grabbing' : (node ? 'pointer' : 'grab');
1327
+ if (node) {
1328
+ const metrics = ((node.metadata || {}).graph_metrics) || {};
498
1329
  tooltip.style.display = 'block';
499
- tooltip.style.left = (e.clientX + 14) + 'px';
500
- tooltip.style.top = (e.clientY - 8) + 'px';
501
- tooltip.textContent = n.title;
502
- } else { tooltip.style.display = 'none'; }
1330
+ tooltip.style.left = `${event.clientX + 14}px`;
1331
+ tooltip.style.top = `${event.clientY - 8}px`;
1332
+ tooltip.innerHTML = `
1333
+ <strong>${escapeHtml(node.title)}</strong><br>
1334
+ ${escapeHtml(typeLabel(node.type))} · importance ${escapeHtml(formatMetric((node.importance_norm || 0) * 100, 0))}%<br>
1335
+ ${node.type === 'Topic'
1336
+ ? `mentions ${escapeHtml(formatMetric(metrics.mention_count || 0, 0))} · conversations ${escapeHtml(formatMetric(metrics.conversation_count || 0, 0))}`
1337
+ : `connections ${escapeHtml(formatMetric(metrics.degree || node.degree || 0, 0))}`
1338
+ }
1339
+ `;
1340
+ } else {
1341
+ tooltip.style.display = 'none';
1342
+ }
1343
+ });
1344
+
1345
+ canvas.addEventListener('mouseleave', () => {
1346
+ hovered = null;
1347
+ tooltip.style.display = 'none';
1348
+ wakeUp();
503
1349
  });
504
- canvas.addEventListener('mouseleave', () => { hovered = null; tooltip.style.display = 'none'; wakeUp(); });
505
1350
 
506
- window.addEventListener('mousemove', e => {
1351
+ window.addEventListener('mousemove', event => {
507
1352
  if (dragging) {
508
1353
  const rect = canvas.getBoundingClientRect();
509
- const w = toWorld(e.clientX - rect.left, e.clientY - rect.top);
510
- dragging.x = w.x; dragging.y = w.y; dragging.vx = 0; dragging.vy = 0;
1354
+ const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
1355
+ dragging.x = world.x;
1356
+ dragging.y = world.y;
1357
+ dragging.vx = 0;
1358
+ dragging.vy = 0;
511
1359
  wakeUp();
512
1360
  } else if (panning) {
513
- cam.tx = panning.tx0 + (e.clientX - panning.sx);
514
- cam.ty = panning.ty0 + (e.clientY - panning.sy);
1361
+ cam.tx = panning.tx0 + (event.clientX - panning.sx);
1362
+ cam.ty = panning.ty0 + (event.clientY - panning.sy);
515
1363
  wakeUp();
516
1364
  }
517
1365
  });
518
1366
 
519
1367
  window.addEventListener('mouseup', () => {
520
- dragging = null; panning = null;
1368
+ dragging = null;
1369
+ panning = null;
521
1370
  canvas.classList.remove('panning');
522
1371
  });
523
1372
 
524
- canvas.addEventListener('wheel', e => {
525
- e.preventDefault();
1373
+ canvas.addEventListener('wheel', event => {
1374
+ event.preventDefault();
526
1375
  const rect = canvas.getBoundingClientRect();
527
- const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
528
- const f = e.deltaY < 0 ? 1.12 : 1 / 1.12;
529
- const ns = Math.min(6, Math.max(0.07, cam.scale * f));
530
- cam.tx = cx - (cx - cam.tx) * (ns / cam.scale);
531
- cam.ty = cy - (cy - cam.ty) * (ns / cam.scale);
532
- cam.scale = ns;
1376
+ const canvasX = event.clientX - rect.left;
1377
+ const canvasY = event.clientY - rect.top;
1378
+ const zoomFactor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
1379
+ const nextScale = clamp(cam.scale * zoomFactor, 0.07, 6);
1380
+ cam.tx = canvasX - (canvasX - cam.tx) * (nextScale / cam.scale);
1381
+ cam.ty = canvasY - (canvasY - cam.ty) * (nextScale / cam.scale);
1382
+ cam.scale = nextScale;
533
1383
  wakeUp();
534
1384
  }, { passive: false });
535
1385
 
536
- // ── Touch events ──────────────────────────────────────────────────────────
537
- let lastTouchDist = null;
538
- canvas.addEventListener('touchstart', e => {
539
- e.preventDefault();
540
- if (e.touches.length === 2) {
541
- lastTouchDist = Math.hypot(
542
- e.touches[0].clientX - e.touches[1].clientX,
543
- e.touches[0].clientY - e.touches[1].clientY,
1386
+ let lastTouchDistance = null;
1387
+ canvas.addEventListener('touchstart', event => {
1388
+ event.preventDefault();
1389
+ if (event.touches.length === 2) {
1390
+ lastTouchDistance = Math.hypot(
1391
+ event.touches[0].clientX - event.touches[1].clientX,
1392
+ event.touches[0].clientY - event.touches[1].clientY
544
1393
  );
545
1394
  dragging = null;
546
1395
  return;
547
1396
  }
548
- const t = e.touches[0];
1397
+ const touch = event.touches[0];
549
1398
  const rect = canvas.getBoundingClientRect();
550
- const n = nodeAt(t.clientX - rect.left, t.clientY - rect.top);
551
- if (n) { dragging = n; showDetail(n); wakeUp(); }
552
- else panning = { sx: t.clientX, sy: t.clientY, tx0: cam.tx, ty0: cam.ty };
1399
+ const node = nodeAt(touch.clientX - rect.left, touch.clientY - rect.top);
1400
+ if (node) {
1401
+ dragging = node;
1402
+ showDetail(node);
1403
+ } else {
1404
+ panning = { sx: touch.clientX, sy: touch.clientY, tx0: cam.tx, ty0: cam.ty };
1405
+ }
1406
+ wakeUp();
553
1407
  }, { passive: false });
554
1408
 
555
- canvas.addEventListener('touchmove', e => {
556
- e.preventDefault();
557
- if (e.touches.length === 2) {
558
- const dist = Math.hypot(
559
- e.touches[0].clientX - e.touches[1].clientX,
560
- e.touches[0].clientY - e.touches[1].clientY,
1409
+ canvas.addEventListener('touchmove', event => {
1410
+ event.preventDefault();
1411
+ if (event.touches.length === 2) {
1412
+ const distance = Math.hypot(
1413
+ event.touches[0].clientX - event.touches[1].clientX,
1414
+ event.touches[0].clientY - event.touches[1].clientY
561
1415
  );
562
- if (lastTouchDist) {
563
- const f = dist / lastTouchDist;
564
- const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
565
- const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
1416
+ if (lastTouchDistance) {
1417
+ const factor = distance / lastTouchDistance;
1418
+ const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
1419
+ const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
566
1420
  const rect = canvas.getBoundingClientRect();
567
- const px = cx - rect.left, py = cy - rect.top;
568
- const ns = Math.min(6, Math.max(0.07, cam.scale * f));
569
- cam.tx = px - (px - cam.tx) * (ns / cam.scale);
570
- cam.ty = py - (py - cam.ty) * (ns / cam.scale);
571
- cam.scale = ns;
1421
+ const px = centerX - rect.left;
1422
+ const py = centerY - rect.top;
1423
+ const nextScale = clamp(cam.scale * factor, 0.07, 6);
1424
+ cam.tx = px - (px - cam.tx) * (nextScale / cam.scale);
1425
+ cam.ty = py - (py - cam.ty) * (nextScale / cam.scale);
1426
+ cam.scale = nextScale;
572
1427
  wakeUp();
573
1428
  }
574
- lastTouchDist = dist;
1429
+ lastTouchDistance = distance;
575
1430
  return;
576
1431
  }
577
- const t = e.touches[0];
1432
+
1433
+ const touch = event.touches[0];
578
1434
  if (dragging) {
579
1435
  const rect = canvas.getBoundingClientRect();
580
- const w = toWorld(t.clientX - rect.left, t.clientY - rect.top);
581
- dragging.x = w.x; dragging.y = w.y; dragging.vx = 0; dragging.vy = 0;
582
- wakeUp();
1436
+ const world = toWorld(touch.clientX - rect.left, touch.clientY - rect.top);
1437
+ dragging.x = world.x;
1438
+ dragging.y = world.y;
1439
+ dragging.vx = 0;
1440
+ dragging.vy = 0;
583
1441
  } else if (panning) {
584
- cam.tx = panning.tx0 + (t.clientX - panning.sx);
585
- cam.ty = panning.ty0 + (t.clientY - panning.sy);
586
- wakeUp();
1442
+ cam.tx = panning.tx0 + (touch.clientX - panning.sx);
1443
+ cam.ty = panning.ty0 + (touch.clientY - panning.sy);
587
1444
  }
1445
+ wakeUp();
588
1446
  }, { passive: false });
589
1447
 
590
- canvas.addEventListener('touchend', () => { dragging = null; panning = null; lastTouchDist = null; });
1448
+ canvas.addEventListener('touchend', () => {
1449
+ dragging = null;
1450
+ panning = null;
1451
+ lastTouchDistance = null;
1452
+ });
1453
+
1454
+ searchInput.addEventListener('input', scheduleSearch);
1455
+ searchInput.addEventListener('keydown', event => {
1456
+ if (event.key === 'Enter' && searchResults.length) {
1457
+ event.preventDefault();
1458
+ focusSearchResult(searchResults[0]).catch(error => {
1459
+ searchCountEl.textContent = 'Error';
1460
+ searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
1461
+ });
1462
+ }
1463
+ });
591
1464
 
592
- // ── Controls ──────────────────────────────────────────────────────────────
593
- search.addEventListener('input', wakeUp);
1465
+ document.getElementById('clear-search-btn').addEventListener('click', clearSearch);
594
1466
  document.getElementById('fit-btn').addEventListener('click', fitToScreen);
595
1467
  document.getElementById('refresh-btn').addEventListener('click', () => {
596
1468
  rawGraph = { nodes: [], edges: [] };
597
- graph = { nodes: [], edges: [] };
598
- loadGraph().catch(err => {
599
- detail.innerHTML = `<div class="type-badge" style="background:#e47a73;color:#fff">Error</div><div class="detail-title">불러오기 실패</div><div class="detail-summary">${err.message}</div>`;
1469
+ graph = { nodes: [], edges: [] };
1470
+ selected = null;
1471
+ loadGraph().catch(error => {
1472
+ detail.innerHTML = `<div class="type-badge" style="background:${nodeColor('ClearEvent')}; color:#091019">Error</div><div class="detail-title">그래프를 새로고침하지 못했습니다.</div><div class="detail-summary">${escapeHtml(error.message)}</div>`;
600
1473
  });
601
1474
  });
602
- document.getElementById('back-btn').addEventListener('click', () => window.location.href = `${API_BASE}/chat`);
603
- window.addEventListener('resize', () => { resize(); wakeUp(); });
1475
+ document.getElementById('back-btn').addEventListener('click', () => {
1476
+ window.location.href = `${API_BASE}/chat`;
1477
+ });
1478
+
1479
+ searchResultsEl.addEventListener('click', event => {
1480
+ const target = event.target.closest('[data-node-id]');
1481
+ if (!target) return;
1482
+ const match = searchResults.find(item => item.id === target.dataset.nodeId);
1483
+ if (!match) return;
1484
+ focusSearchResult(match).catch(error => {
1485
+ searchCountEl.textContent = 'Error';
1486
+ searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
1487
+ });
1488
+ });
1489
+
1490
+ window.addEventListener('resize', () => {
1491
+ resize();
1492
+ wakeUp();
1493
+ });
604
1494
 
605
- // ── Init ──────────────────────────────────────────────────────────────────
606
1495
  resize();
607
- loadGraph().catch(err => {
608
- detail.innerHTML = `<div class="type-badge" style="background:#e47a73;color:#fff">Error</div><div class="detail-title">그래프를 불러오지 못했습니다.</div><div class="detail-summary">${err.message}</div>`;
1496
+ renderSearchResults();
1497
+ loadGraph().catch(error => {
1498
+ detail.innerHTML = `
1499
+ <div class="type-badge" style="background:${nodeColor('ClearEvent')}">Error</div>
1500
+ <div class="detail-title">그래프를 불러오지 못했습니다.</div>
1501
+ <div class="detail-summary">${escapeHtml(error.message)}</div>
1502
+ `;
609
1503
  });
610
1504
  </script>
611
1505
  </body>