ltcai 0.1.4 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,612 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Lattice AI — Knowledge Graph</title>
7
+ <style>
8
+ :root {
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;
17
+ }
18
+ * { 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; }
21
+
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
+ canvas.panning { cursor: grabbing; }
26
+
27
+ .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); }
40
+ .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;
44
+ }
45
+ .tb-btn:hover { border-color: var(--accent); color: var(--accent); }
46
+
47
+ #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;
52
+ }
53
+
54
+ /* Sidebar */
55
+ aside {
56
+ background: var(--panel); display: flex; flex-direction: column;
57
+ overflow: hidden;
58
+ }
59
+ .sidebar-head {
60
+ padding: 16px 16px 0; flex-shrink: 0;
61
+ }
62
+ h1 { margin: 0 0 12px; font-size: 17px; font-weight: 700; }
63
+
64
+ .stats-row {
65
+ display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;
66
+ }
67
+ .stat {
68
+ border: 1px solid var(--line); border-radius: 8px;
69
+ padding: 9px 10px; background: #0e1114;
70
+ }
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
+
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;
78
+ }
79
+ .filter-grid { display: flex; flex-direction: column; gap: 3px; }
80
+ .filter-item {
81
+ display: flex; align-items: center; gap: 7px;
82
+ padding: 3px 0; cursor: pointer; font-size: 12px; user-select: none;
83
+ }
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
+
89
+ .detail-wrap { flex: 1; overflow-y: auto; padding: 14px 16px 20px; }
90
+ .type-badge {
91
+ display: inline-block; color: #07100d; border-radius: 999px;
92
+ padding: 2px 10px; font-size: 11px; font-weight: 700; margin-bottom: 9px;
93
+ }
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; }
97
+ .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;
103
+ }
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
+
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; }
112
+ }
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <div class="app">
117
+ <main class="stage">
118
+ <canvas id="graph"></canvas>
119
+ <div class="toolbar">
120
+ <input id="search" placeholder="Search nodes…" autocomplete="off">
121
+ <button class="tb-btn" id="fit-btn">Fit</button>
122
+ <button class="tb-btn" id="refresh-btn">Refresh</button>
123
+ <button class="tb-btn" id="back-btn">← Chat</button>
124
+ </div>
125
+ </main>
126
+ <aside>
127
+ <div class="sidebar-head">
128
+ <h1>Knowledge Graph</h1>
129
+ <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>
132
+ </div>
133
+ </div>
134
+ <div class="type-filters" id="type-filters"></div>
135
+ <div class="detail-wrap">
136
+ <div id="detail">
137
+ <p class="empty-hint">채팅과 파일이 쌓이면 여기에 연결 관계가 나타납니다.<br>노드를 클릭하면 원문 요약과 메타데이터를 볼 수 있습니다.</p>
138
+ </div>
139
+ </div>
140
+ </aside>
141
+ </div>
142
+ <div id="tooltip"></div>
143
+
144
+ <script>
145
+ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
146
+
147
+ 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' },
161
+ };
162
+
163
+ const canvas = document.getElementById('graph');
164
+ const ctx = canvas.getContext('2d');
165
+ const detail = document.getElementById('detail');
166
+ const search = document.getElementById('search');
167
+ const tooltip = document.getElementById('tooltip');
168
+
169
+ let rawGraph = { nodes: [], edges: [] };
170
+ let graph = { nodes: [], edges: [] };
171
+ let hiddenTypes = new Set();
172
+ let selected = null;
173
+ let hovered = null;
174
+ let dragging = null;
175
+ let panning = null;
176
+ let cam = { scale: 1, tx: 0, ty: 0 };
177
+ let animFrameId = null;
178
+ let width = 0, height = 0;
179
+
180
+ // ── Auth ─────────────────────────────────────────────────────────────────
181
+ function authHeaders() {
182
+ const t = localStorage.getItem('ltcai_session_token') || '';
183
+ return t ? { Authorization: `Bearer ${t}` } : {};
184
+ }
185
+ function apiFetch(path, opts = {}) {
186
+ return fetch(`${API_BASE}${path}`, {
187
+ credentials: 'include',
188
+ ...opts,
189
+ headers: { ...authHeaders(), ...(opts.headers || {}) },
190
+ });
191
+ }
192
+
193
+ // ── Color ─────────────────────────────────────────────────────────────────
194
+ function nodeColor(type) {
195
+ return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
196
+ }
197
+
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;
204
+ });
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);
213
+ });
214
+ }
215
+
216
+ 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]));
220
+ 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] }));
223
+ }
224
+
225
+ 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;
231
+ }
232
+ n.vx = n.vx || 0;
233
+ n.vy = n.vy || 0;
234
+ });
235
+ }
236
+
237
+ // ── Load ──────────────────────────────────────────────────────────────────
238
+ async function loadGraph() {
239
+ document.getElementById('node-count').textContent = '…';
240
+ document.getElementById('edge-count').textContent = '…';
241
+
242
+ const [gRes, sRes] = await Promise.all([
243
+ apiFetch('/knowledge-graph/graph?limit=600'),
244
+ apiFetch('/knowledge-graph/stats'),
245
+ ]);
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() : {};
253
+
254
+ computeDegrees();
255
+ seedLayout();
256
+ 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);
262
+ cam = { scale: 1, tx: 0, ty: 0 };
263
+ wakeUp();
264
+ }
265
+
266
+ // ── Type filters ──────────────────────────────────────────────────────────
267
+ 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));
270
+ 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>`;
283
+ }).join('');
284
+ container.innerHTML = `<div class="filter-section-label">Node types</div><div class="filter-grid">${items}</div>`;
285
+ }
286
+
287
+ function toggleType(type, visible) {
288
+ if (visible) hiddenTypes.delete(type); else hiddenTypes.add(type);
289
+ applyFilter();
290
+ wakeUp();
291
+ }
292
+
293
+ // ── Physics ───────────────────────────────────────────────────────────────
294
+ function step() {
295
+ const nodes = graph.nodes;
296
+ const edges = graph.edges;
297
+
298
+ for (let i = 0; i < nodes.length; i++) {
299
+ 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;
306
+ }
307
+ }
308
+
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;
312
+ 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;
317
+ });
318
+
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;
327
+ });
328
+ return ke;
329
+ }
330
+
331
+ function wakeUp() {
332
+ if (!animFrameId) animFrameId = requestAnimationFrame(draw);
333
+ }
334
+
335
+ // ── Draw ──────────────────────────────────────────────────────────────────
336
+ const nbCache = new Map();
337
+ function neighborIds(node) {
338
+ if (nbCache.has(node.id)) return nbCache.get(node.id);
339
+ 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);
343
+ });
344
+ nbCache.set(node.id, ids);
345
+ return ids;
346
+ }
347
+
348
+ function draw() {
349
+ animFrameId = null;
350
+ const ke = step();
351
+ nbCache.clear();
352
+
353
+ ctx.clearRect(0, 0, width, height);
354
+ ctx.save();
355
+ ctx.translate(cam.tx, cam.ty);
356
+ ctx.scale(cam.scale, cam.scale);
357
+
358
+ 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;
370
+ ctx.beginPath();
371
+ ctx.moveTo(e.source.x, e.source.y);
372
+ ctx.lineTo(e.target.x, e.target.y);
373
+ ctx.stroke();
374
+ });
375
+
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);
384
+
385
+ ctx.globalAlpha = alpha;
386
+ ctx.beginPath();
387
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
388
+ ctx.fillStyle = isSel ? '#ffffff' : nodeColor(n.type);
389
+ ctx.fill();
390
+
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;
394
+ ctx.beginPath();
395
+ ctx.arc(n.x, n.y, r + 3.5, 0, Math.PI * 2);
396
+ ctx.stroke();
397
+ }
398
+
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');
403
+ 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);
407
+ }
408
+ ctx.globalAlpha = 1;
409
+ });
410
+
411
+ ctx.restore();
412
+ if (ke > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
413
+ }
414
+
415
+ // ── Coordinates ───────────────────────────────────────────────────────────
416
+ function toWorld(cx, cy) {
417
+ return { x: (cx - cam.tx) / cam.scale, y: (cy - cam.ty) / cam.scale };
418
+ }
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; }
425
+ });
426
+ return best;
427
+ }
428
+
429
+ // ── Fit to screen ─────────────────────────────────────────────────────────
430
+ function fitToScreen() {
431
+ if (!graph.nodes.length) return;
432
+ 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);
436
+ });
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;
445
+ wakeUp();
446
+ }
447
+
448
+ // ── Detail panel ──────────────────────────────────────────────────────────
449
+ function showDetail(node) {
450
+ if (!node) {
451
+ detail.innerHTML = '<p class="empty-hint">노드를 클릭하면 원문 요약과 메타데이터를 볼 수 있습니다.</p>';
452
+ return;
453
+ }
454
+ selected = node;
455
+ const meta = node.metadata || {};
456
+ const convId = meta.conversation_id;
457
+ const jumpHtml = convId
458
+ ? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">→ 채팅에서 열기</a>`
459
+ : '';
460
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
461
+ 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>' : ''}
465
+ ${jumpHtml}
466
+ ${metaStr ? '<div class="detail-meta"></div>' : ''}
467
+ `;
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;
471
+ }
472
+
473
+ // ── Canvas resize ─────────────────────────────────────────────────────────
474
+ function resize() {
475
+ const rect = canvas.getBoundingClientRect();
476
+ width = rect.width; height = rect.height;
477
+ const dpr = window.devicePixelRatio || 1;
478
+ canvas.width = Math.floor(width * dpr);
479
+ canvas.height = Math.floor(height * dpr);
480
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
481
+ }
482
+
483
+ // ── Mouse events ──────────────────────────────────────────────────────────
484
+ canvas.addEventListener('mousedown', e => {
485
+ 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'); }
490
+ });
491
+
492
+ canvas.addEventListener('mousemove', e => {
493
+ 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) {
498
+ 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'; }
503
+ });
504
+ canvas.addEventListener('mouseleave', () => { hovered = null; tooltip.style.display = 'none'; wakeUp(); });
505
+
506
+ window.addEventListener('mousemove', e => {
507
+ if (dragging) {
508
+ 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;
511
+ wakeUp();
512
+ } else if (panning) {
513
+ cam.tx = panning.tx0 + (e.clientX - panning.sx);
514
+ cam.ty = panning.ty0 + (e.clientY - panning.sy);
515
+ wakeUp();
516
+ }
517
+ });
518
+
519
+ window.addEventListener('mouseup', () => {
520
+ dragging = null; panning = null;
521
+ canvas.classList.remove('panning');
522
+ });
523
+
524
+ canvas.addEventListener('wheel', e => {
525
+ e.preventDefault();
526
+ 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;
533
+ wakeUp();
534
+ }, { passive: false });
535
+
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,
544
+ );
545
+ dragging = null;
546
+ return;
547
+ }
548
+ const t = e.touches[0];
549
+ 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 };
553
+ }, { passive: false });
554
+
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,
561
+ );
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;
566
+ 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;
572
+ wakeUp();
573
+ }
574
+ lastTouchDist = dist;
575
+ return;
576
+ }
577
+ const t = e.touches[0];
578
+ if (dragging) {
579
+ 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();
583
+ } else if (panning) {
584
+ cam.tx = panning.tx0 + (t.clientX - panning.sx);
585
+ cam.ty = panning.ty0 + (t.clientY - panning.sy);
586
+ wakeUp();
587
+ }
588
+ }, { passive: false });
589
+
590
+ canvas.addEventListener('touchend', () => { dragging = null; panning = null; lastTouchDist = null; });
591
+
592
+ // ── Controls ──────────────────────────────────────────────────────────────
593
+ search.addEventListener('input', wakeUp);
594
+ document.getElementById('fit-btn').addEventListener('click', fitToScreen);
595
+ document.getElementById('refresh-btn').addEventListener('click', () => {
596
+ 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>`;
600
+ });
601
+ });
602
+ document.getElementById('back-btn').addEventListener('click', () => window.location.href = `${API_BASE}/chat`);
603
+ window.addEventListener('resize', () => { resize(); wakeUp(); });
604
+
605
+ // ── Init ──────────────────────────────────────────────────────────────────
606
+ 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>`;
609
+ });
610
+ </script>
611
+ </body>
612
+ </html>
Binary file
Binary file
Binary file
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "Lattice AI",
3
+ "short_name": "LatticeAI",
4
+ "description": "Local AI agent workspace — MLX & cloud LLMs",
5
+ "start_url": "/",
6
+ "id": "/",
7
+ "display": "standalone",
8
+ "orientation": "any",
9
+ "background_color": "#141715",
10
+ "theme_color": "#2d5a3d",
11
+ "lang": "ko",
12
+ "icons": [
13
+ {
14
+ "src": "/icons/icon-192.png",
15
+ "sizes": "192x192",
16
+ "type": "image/png",
17
+ "purpose": "any maskable"
18
+ },
19
+ {
20
+ "src": "/icons/icon-512.png",
21
+ "sizes": "512x512",
22
+ "type": "image/png",
23
+ "purpose": "any maskable"
24
+ }
25
+ ],
26
+ "categories": ["productivity", "utilities"],
27
+ "shortcuts": [
28
+ {
29
+ "name": "새 대화",
30
+ "short_name": "대화",
31
+ "url": "/",
32
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
33
+ }
34
+ ]
35
+ }