ltcai 2.2.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -143
- package/docs/CHANGELOG.md +66 -23
- package/docs/EDITION_STRATEGY.md +8 -8
- package/docs/ENTERPRISE.md +5 -5
- package/docs/PLUGIN_SDK.md +4 -4
- package/docs/V2_ARCHITECTURE.md +9 -9
- package/docs/architecture.md +16 -15
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/spec-vs-impl.md +13 -10
- package/latticeai/__init__.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +14 -6
- package/static/account.html +3 -1
- package/static/activity.html +5 -2
- package/static/admin.html +5 -1
- package/static/agents.html +5 -2
- package/static/chat.html +7 -4
- package/static/css/responsive.css +614 -0
- package/static/css/tokens.css +224 -165
- package/static/graph.html +12 -2
- package/static/lattice-reference.css +366 -739
- package/static/platform.css +45 -16
- package/static/plugins.html +5 -2
- package/static/scripts/admin.js +33 -33
- package/static/scripts/chat.js +84 -15
- package/static/scripts/graph.js +169 -11
- package/static/scripts/ux.js +167 -0
- package/static/workflows.html +5 -2
- package/static/workspace.css +60 -19
- package/static/workspace.html +5 -2
package/static/platform.css
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/* Lattice AI v2.0 — shared styling for the Agentic Workspace Platform pages
|
|
2
2
|
(Plugin SDK, Workflow Designer, Multi-Agent Runtime, Realtime Activity). */
|
|
3
3
|
:root {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
4
|
+
/* Consume tokens.css semantic tokens so these pages follow light & dark themes.
|
|
5
|
+
tokens.css is linked before platform.css, so var() resolves at runtime. */
|
|
6
|
+
--bg: var(--lt-bg, #0f1115);
|
|
7
|
+
--panel: var(--lt-surface, #16191f);
|
|
8
|
+
--panel-2: var(--lt-surface-2, #1c2027);
|
|
9
|
+
--border: var(--lt-line, rgba(255, 255, 255, 0.08));
|
|
10
|
+
--text: var(--lt-ink, #e7ecf3);
|
|
11
|
+
--muted: var(--lt-ink-soft, #64748b);
|
|
12
|
+
--accent: var(--lt-accent, #6E4AE6);
|
|
13
|
+
--accent-2: var(--lt-accent, #5ea7ec);
|
|
12
14
|
--ok: #34d399;
|
|
13
15
|
--warn: #fbbf24;
|
|
14
16
|
--err: #f87171;
|
|
15
17
|
}
|
|
16
18
|
* { box-sizing: border-box; }
|
|
19
|
+
html, body { overflow-x: hidden; }
|
|
20
|
+
body { min-width: 320px; }
|
|
17
21
|
body {
|
|
18
22
|
margin: 0;
|
|
19
23
|
background: var(--bg);
|
|
@@ -27,15 +31,15 @@ header.app {
|
|
|
27
31
|
padding: 14px 24px; border-bottom: 1px solid var(--border);
|
|
28
32
|
background: rgba(22, 25, 31, 0.8); position: sticky; top: 0; backdrop-filter: blur(8px); z-index: 5;
|
|
29
33
|
}
|
|
30
|
-
header.app .brand { font-weight: 700; font-size: 16px; color:
|
|
34
|
+
header.app .brand { font-weight: 700; font-size: 16px; color: var(--text); letter-spacing: .3px; }
|
|
31
35
|
header.app .brand small { color: var(--accent); font-weight: 600; margin-left: 6px; }
|
|
32
36
|
header.app nav { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
33
|
-
header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; }
|
|
34
|
-
header.app nav a:hover, header.app nav a.active { color:
|
|
37
|
+
header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; min-height: 44px; display: inline-flex; align-items: center; }
|
|
38
|
+
header.app nav a:hover, header.app nav a.active { color: var(--text); background: var(--panel-2); }
|
|
35
39
|
main { max-width: 1080px; margin: 0 auto; padding: 28px 24px 80px; }
|
|
36
40
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
37
41
|
.sub { color: var(--muted); font-size: 13px; margin: 0 0 24px; }
|
|
38
|
-
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(
|
|
42
|
+
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }
|
|
39
43
|
.card {
|
|
40
44
|
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
|
|
41
45
|
padding: 16px 18px;
|
|
@@ -54,22 +58,47 @@ h1 { font-size: 22px; margin: 0 0 4px; }
|
|
|
54
58
|
button, .btn {
|
|
55
59
|
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
|
56
60
|
padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
|
|
61
|
+
min-height: 44px;
|
|
57
62
|
}
|
|
58
63
|
button.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
|
59
64
|
button:hover { filter: brightness(1.08); }
|
|
60
65
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
61
66
|
textarea, input, select {
|
|
62
67
|
width: 100%; background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
|
63
|
-
border-radius: 8px; padding: 9px 11px; font-size:
|
|
68
|
+
border-radius: 8px; padding: 9px 11px; font-size: 16px; font-family: inherit;
|
|
69
|
+
min-height: 44px;
|
|
64
70
|
}
|
|
65
71
|
textarea { min-height: 90px; resize: vertical; }
|
|
66
72
|
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
|
67
73
|
pre {
|
|
68
|
-
background:
|
|
69
|
-
padding: 12px; overflow: auto; font-size: 12px; color:
|
|
74
|
+
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
|
|
75
|
+
padding: 12px; overflow: auto; font-size: 12px; color: var(--text); max-height: 360px;
|
|
76
|
+
max-width: 100%; overflow-wrap: anywhere;
|
|
70
77
|
}
|
|
71
78
|
.empty { color: var(--muted); text-align: center; padding: 50px 0; }
|
|
72
79
|
.section { margin-top: 28px; }
|
|
73
80
|
.timeline-item { border-left: 2px solid var(--border); padding: 6px 0 6px 14px; margin-left: 6px; font-size: 13px; }
|
|
74
81
|
.timeline-item .t-meta { color: var(--muted); font-size: 11px; }
|
|
75
|
-
.toast {
|
|
82
|
+
.toast {
|
|
83
|
+
position: fixed;
|
|
84
|
+
bottom: max(20px, env(safe-area-inset-bottom));
|
|
85
|
+
left: auto;
|
|
86
|
+
right: max(12px, env(safe-area-inset-right));
|
|
87
|
+
background: var(--panel-2); border: 1px solid var(--border);
|
|
88
|
+
padding: 12px 16px; border-radius: 10px; font-size: 13px;
|
|
89
|
+
max-width: min(360px, calc(100vw - 24px));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 키보드 포커스 링 (tokens.css 의 :focus-visible 와 별개로 이 페이지에서도 보장) */
|
|
93
|
+
:focus-visible {
|
|
94
|
+
outline: 2px solid var(--accent, #6E4AE6);
|
|
95
|
+
outline-offset: 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* 폰: 패딩 축소 + 단일 열 + 헤더 컴팩트 */
|
|
99
|
+
@media (max-width: 600px) {
|
|
100
|
+
header.app { padding: 12px 14px; }
|
|
101
|
+
main { padding: 18px 14px 64px; }
|
|
102
|
+
.grid { grid-template-columns: 1fr; }
|
|
103
|
+
h1 { font-size: 20px; }
|
|
104
|
+
}
|
package/static/plugins.html
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
6
6
|
<title>Plugin SDK — Lattice AI</title>
|
|
7
|
-
<
|
|
7
|
+
<script src="/static/scripts/ux.js?v=2.2.2"></script>
|
|
8
|
+
<link rel="stylesheet" href="/static/css/tokens.css?v=2.2.2" />
|
|
9
|
+
<link rel="stylesheet" href="/static/platform.css?v=2.2.2" />
|
|
10
|
+
<link rel="stylesheet" href="/static/css/responsive.css?v=2.2.2" />
|
|
8
11
|
</head>
|
|
9
12
|
<body>
|
|
10
13
|
<main>
|
package/static/scripts/admin.js
CHANGED
|
@@ -587,12 +587,12 @@ function renderUsers(users) {
|
|
|
587
587
|
<tbody>
|
|
588
588
|
${latestUsers.map(user => `
|
|
589
589
|
<tr>
|
|
590
|
-
<td>${esc(user.email)}</td>
|
|
591
|
-
<td>${esc(user.name || '-')}</td>
|
|
592
|
-
<td>${esc(user.nickname || '-')}</td>
|
|
593
|
-
<td><span class="role">${esc(roleLabel(user.role))}</span></td>
|
|
594
|
-
<td>${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
|
|
595
|
-
<td>
|
|
590
|
+
<td data-label="${t('label_email')}">${esc(user.email)}</td>
|
|
591
|
+
<td data-label="${t('label_name')}">${esc(user.name || '-')}</td>
|
|
592
|
+
<td data-label="${t('label_nickname')}">${esc(user.nickname || '-')}</td>
|
|
593
|
+
<td data-label="${t('label_perm')}"><span class="role">${esc(roleLabel(user.role))}</span></td>
|
|
594
|
+
<td data-label="${t('label_status')}">${permissionTag(statusLabel(user), user.disabled ? 'medium' : 'low')}</td>
|
|
595
|
+
<td data-label="${t('label_actions')}">
|
|
596
596
|
<div class="actions">
|
|
597
597
|
<button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
|
|
598
598
|
${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
|
|
@@ -635,15 +635,15 @@ function renderPermissions(users) {
|
|
|
635
635
|
const isAdmin = user.role === 'admin';
|
|
636
636
|
return `
|
|
637
637
|
<tr>
|
|
638
|
-
<td>
|
|
638
|
+
<td data-label="${t('label_user')}">
|
|
639
639
|
<strong>${esc(user.nickname || user.name || user.email)}</strong>
|
|
640
640
|
<div class="preview">${esc(user.email)}</div>
|
|
641
641
|
</td>
|
|
642
|
-
<td>${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
|
|
643
|
-
<td>${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
644
|
-
<td>${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
645
|
-
<td>${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
|
|
646
|
-
<td>
|
|
642
|
+
<td data-label="${t('label_status')}">${permissionTag(statusLabel(user), active ? 'low' : 'medium')}</td>
|
|
643
|
+
<td data-label="${t('permission_default')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
644
|
+
<td data-label="${t('permission_advanced')}">${permissionTag(active ? t('permission_allowed') : t('permission_blocked'), active ? 'low' : 'medium')}</td>
|
|
645
|
+
<td data-label="${t('permission_admin')}">${permissionTag(isAdmin && active ? t('permission_granted') : t('permission_not_granted'), isAdmin && active ? 'low' : 'medium')}</td>
|
|
646
|
+
<td data-label="${t('label_actions')}">
|
|
647
647
|
<div class="actions">
|
|
648
648
|
<button class="table-btn" data-action="role" data-email="${esc(user.email)}" data-next-role="${isAdmin ? 'user' : 'admin'}">
|
|
649
649
|
${isAdmin ? t('btn_revoke_admin') : t('btn_grant_admin')}
|
|
@@ -773,12 +773,12 @@ function renderAudit(audit) {
|
|
|
773
773
|
<tbody>
|
|
774
774
|
${users.map(user => `
|
|
775
775
|
<tr>
|
|
776
|
-
<td><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
|
|
777
|
-
<td>${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
|
|
778
|
-
<td>${esc(user.document_uploads || 0)}</td>
|
|
779
|
-
<td>${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
|
|
780
|
-
<td>${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
|
|
781
|
-
<td>${esc(formatTime(user.last_activity_at))}</td>
|
|
776
|
+
<td data-label="${t('label_user')}"><strong>${esc(user.nickname || user.email || 'Unknown')}</strong><div class="preview">${esc(user.email || '')}</div></td>
|
|
777
|
+
<td data-label="AI Use">${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
|
|
778
|
+
<td data-label="Uploads">${esc(user.document_uploads || 0)}</td>
|
|
779
|
+
<td data-label="Sensitive">${permissionTag(user.sensitive_events || 0, (user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low'))}</td>
|
|
780
|
+
<td data-label="Clear/Delete">${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
|
|
781
|
+
<td data-label="Last Active">${esc(formatTime(user.last_activity_at))}</td>
|
|
782
782
|
</tr>
|
|
783
783
|
`).join('')}
|
|
784
784
|
</tbody>
|
|
@@ -800,11 +800,11 @@ function renderAudit(audit) {
|
|
|
800
800
|
<tbody>
|
|
801
801
|
${events.map(event => `
|
|
802
802
|
<tr>
|
|
803
|
-
<td>${esc(formatTime(event.timestamp))}</td>
|
|
804
|
-
<td>${esc(auditEventLabel(event))}</td>
|
|
805
|
-
<td>${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
|
|
806
|
-
<td>${esc(auditTarget(event))}</td>
|
|
807
|
-
<td>${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
|
|
803
|
+
<td data-label="Time">${esc(formatTime(event.timestamp))}</td>
|
|
804
|
+
<td data-label="Event">${esc(auditEventLabel(event))}</td>
|
|
805
|
+
<td data-label="${t('label_user')}">${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
|
|
806
|
+
<td data-label="Target/Data">${esc(auditTarget(event))}</td>
|
|
807
|
+
<td data-label="Risk">${permissionTag(event.sensitivity || 'none', event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low'))}</td>
|
|
808
808
|
</tr>
|
|
809
809
|
`).join('')}
|
|
810
810
|
</tbody>
|
|
@@ -1377,16 +1377,16 @@ function renderCcUsersTable(users) {
|
|
|
1377
1377
|
}
|
|
1378
1378
|
const rows = users.slice(0, 25).map(u => `
|
|
1379
1379
|
<tr data-cc-user="${ccEscape(u.email)}" style="cursor:pointer">
|
|
1380
|
-
<td>${ccEscape(u.user)}</td>
|
|
1381
|
-
<td>${ccEscape(u.total_chats)}</td>
|
|
1382
|
-
<td style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
|
|
1383
|
-
<td style="color:#b13030">${ccEscape(u.risky_chats)}</td>
|
|
1384
|
-
<td>${ccEscape(u.uploaded_files)}</td>
|
|
1385
|
-
<td style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
|
|
1386
|
-
<td style="color:#b13030">${ccEscape(u.risky_files)}</td>
|
|
1387
|
-
<td>${ccEscape(u.high_risk_events)}</td>
|
|
1388
|
-
<td>${ccEscape(u.risk_rate)}%</td>
|
|
1389
|
-
<td>${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
|
|
1380
|
+
<td data-label="사용자">${ccEscape(u.user)}</td>
|
|
1381
|
+
<td data-label="총 채팅">${ccEscape(u.total_chats)}</td>
|
|
1382
|
+
<td data-label="준수 채팅" style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
|
|
1383
|
+
<td data-label="위험 채팅" style="color:#b13030">${ccEscape(u.risky_chats)}</td>
|
|
1384
|
+
<td data-label="총 파일">${ccEscape(u.uploaded_files)}</td>
|
|
1385
|
+
<td data-label="준수 파일" style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
|
|
1386
|
+
<td data-label="위험 파일" style="color:#b13030">${ccEscape(u.risky_files)}</td>
|
|
1387
|
+
<td data-label="High">${ccEscape(u.high_risk_events)}</td>
|
|
1388
|
+
<td data-label="위험률">${ccEscape(u.risk_rate)}%</td>
|
|
1389
|
+
<td data-label="마지막 활동">${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
|
|
1390
1390
|
</tr>
|
|
1391
1391
|
`).join('');
|
|
1392
1392
|
wrap.innerHTML = `
|
package/static/scripts/chat.js
CHANGED
|
@@ -1055,15 +1055,22 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1055
1055
|
<div style="font-weight:700">⭐ Best for this PC — ${escapeHtml(top.name || top.id)} ${badge('recommended')}</div>
|
|
1056
1056
|
<div style="font-size:12px;opacity:0.8;margin-top:3px">${escapeHtml(top.reason || '')}</div>
|
|
1057
1057
|
<div style="font-size:12px;margin-top:4px">${escapeHtml(top.size || '')} · ${escapeHtml(ram(top))} · ${escapeHtml(nextStep(rec.engine))}</div>
|
|
1058
|
+
${modelSourceLine(top) ? `<div style="font-size:11px;opacity:0.7;margin-top:3px">${escapeHtml(modelSourceLine(top))}</div>` : ''}
|
|
1058
1059
|
</div>` : '';
|
|
1059
1060
|
|
|
1060
1061
|
const rows = families.map((fam) => {
|
|
1061
1062
|
const best = fam.best;
|
|
1062
|
-
const items = (fam.models || []).map((m) =>
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1063
|
+
const items = (fam.models || []).map((m) => {
|
|
1064
|
+
const src = modelSourceLine(m);
|
|
1065
|
+
return `
|
|
1066
|
+
<div style="padding:4px 0;font-size:12px;opacity:${m.status === 'not_recommended' ? 0.55 : 1}">
|
|
1067
|
+
<div style="display:flex;justify-content:space-between;gap:8px">
|
|
1068
|
+
<span>${escapeHtml(m.name || m.id)}</span>
|
|
1069
|
+
<span style="white-space:nowrap">${escapeHtml(m.size || '')} · ${escapeHtml(ram(m))} ${badge(m.status)}</span>
|
|
1070
|
+
</div>
|
|
1071
|
+
${src ? `<div style="font-size:11px;opacity:0.65;margin-top:2px">${escapeHtml(src)}</div>` : ''}
|
|
1072
|
+
</div>`;
|
|
1073
|
+
}).join('');
|
|
1067
1074
|
return `
|
|
1068
1075
|
<details style="margin:6px 0;border:1px solid var(--border,#e5e7eb);border-radius:8px;padding:8px 10px">
|
|
1069
1076
|
<summary style="cursor:pointer;font-weight:600">${escapeHtml(fam.family)} ${best ? badge(best.status) : ''}${best ? ` <span style="font-weight:400;opacity:0.7">${escapeHtml(best.name || '')}</span>` : ''}</summary>
|
|
@@ -1536,25 +1543,58 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
1536
1543
|
const action = isLocalEngine
|
|
1537
1544
|
? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
|
|
1538
1545
|
: `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
|
|
1539
|
-
const
|
|
1540
|
-
|
|
1541
|
-
model.source_company,
|
|
1542
|
-
model.execution_method,
|
|
1543
|
-
model.internet_requirement,
|
|
1544
|
-
model.model_name || model.name,
|
|
1545
|
-
].filter(Boolean).join(' · ');
|
|
1546
|
-
const detailLine = sourceLine || `${model.id} · ${badge}`;
|
|
1546
|
+
const chipsHtml = modelSourceChipsHtml(model);
|
|
1547
|
+
const detailLine = chipsHtml ? `${model.id} · ${badge}` : `${model.id} · ${badge}`;
|
|
1547
1548
|
return `
|
|
1548
1549
|
<button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
|
|
1549
1550
|
<div>
|
|
1550
1551
|
<strong>${escapeHtml(model.name || compactModelName(model.id))}</strong>
|
|
1551
|
-
|
|
1552
|
+
${chipsHtml}
|
|
1553
|
+
<span>${escapeHtml(detailLine)}</span>
|
|
1552
1554
|
</div>
|
|
1553
1555
|
<i class="ti ${icon}"></i>
|
|
1554
1556
|
</button>
|
|
1555
1557
|
`;
|
|
1556
1558
|
}
|
|
1557
1559
|
|
|
1560
|
+
// 모델 출처 정보를 비전문가도 읽기 쉬운 라벨 칩으로 렌더링한다.
|
|
1561
|
+
// 필드: source_country / source_company / execution_method / internet_requirement / model_name
|
|
1562
|
+
// 누락된 필드는 자동으로 생략(graceful degrade)한다.
|
|
1563
|
+
function modelSourceChips(model) {
|
|
1564
|
+
if (!model) return [];
|
|
1565
|
+
return [
|
|
1566
|
+
['국가', model.source_country],
|
|
1567
|
+
['회사', model.source_company],
|
|
1568
|
+
['실행', model.execution_method],
|
|
1569
|
+
['인터넷', model.internet_requirement],
|
|
1570
|
+
['모델명', model.model_name || model.name],
|
|
1571
|
+
].filter(([, value]) => value != null && String(value).trim() !== '');
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function modelSourceChipsHtml(model) {
|
|
1575
|
+
const chips = modelSourceChips(model);
|
|
1576
|
+
if (!chips.length) return '';
|
|
1577
|
+
const chipStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;'
|
|
1578
|
+
+ 'border:1px solid var(--border,#e5e7eb);border-radius:999px;'
|
|
1579
|
+
+ 'background:var(--surface-2,#f3f4f6);color:var(--text,#111);'
|
|
1580
|
+
+ 'font-size:11px;line-height:1.4;white-space:nowrap;';
|
|
1581
|
+
const inner = chips.map(([label, value]) =>
|
|
1582
|
+
`<span class="model-source-chip" style="${chipStyle}"><b style="font-weight:700;opacity:0.7">${escapeHtml(label)}:</b> ${escapeHtml(String(value))}</span>`
|
|
1583
|
+
).join('');
|
|
1584
|
+
return `<span class="model-source-chips" style="display:flex;flex-wrap:wrap;gap:4px;margin:4px 0 2px">${inner}</span>`;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// 한 줄짜리 평문 출처(국가 · 회사 · 실행 · 인터넷) — 좁은 행/요약용.
|
|
1588
|
+
function modelSourceLine(model) {
|
|
1589
|
+
if (!model) return '';
|
|
1590
|
+
return [
|
|
1591
|
+
model.source_country,
|
|
1592
|
+
model.source_company,
|
|
1593
|
+
model.execution_method,
|
|
1594
|
+
model.internet_requirement,
|
|
1595
|
+
].filter(value => value != null && String(value).trim() !== '').join(' · ');
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1558
1598
|
function normalizedFamily(model) {
|
|
1559
1599
|
const raw = `${model?.family || ''} ${model?.name || ''} ${model?.id || ''}`.toLowerCase();
|
|
1560
1600
|
if (raw.includes('gpt')) return 'GPT';
|
|
@@ -3568,10 +3608,18 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
3568
3608
|
|
|
3569
3609
|
function attachDocument(input) {
|
|
3570
3610
|
const file = input.files[0];
|
|
3611
|
+
if (!file) return;
|
|
3612
|
+
attachDocumentFile(file);
|
|
3613
|
+
input.value = '';
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// 파일을 직접 첨부 (드래그앤드롭 / 붙여넣기 / 파일 선택 공용 경로)
|
|
3617
|
+
function attachDocumentFile(file) {
|
|
3571
3618
|
if (!file) return;
|
|
3572
3619
|
attachedDocFile = file;
|
|
3573
3620
|
attachedDocContent = null;
|
|
3574
3621
|
const row = document.getElementById('attach-preview-row');
|
|
3622
|
+
if (!row) return;
|
|
3575
3623
|
row.style.display = 'flex';
|
|
3576
3624
|
row.innerHTML = `
|
|
3577
3625
|
<div class="attach-chip">
|
|
@@ -3580,9 +3628,30 @@ const chatViewport = document.getElementById('chat-viewport');
|
|
|
3580
3628
|
<button onclick="removeAttachedDoc()" title="제거">×</button>
|
|
3581
3629
|
</div>
|
|
3582
3630
|
<span style="font-size:11px;color:var(--muted);align-self:center">첨부됨 — 전송 시 AI가 파일을 읽습니다</span>`;
|
|
3583
|
-
input.value = '';
|
|
3584
3631
|
}
|
|
3585
3632
|
|
|
3633
|
+
// 채팅 영역에 파일을 끌어다 놓으면 첨부 (Drag & Drop)
|
|
3634
|
+
(function setupChatDropZone() {
|
|
3635
|
+
const zone = document.querySelector('.main-chat') || document.body;
|
|
3636
|
+
if (!zone) return;
|
|
3637
|
+
const hasFiles = (e) => e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') !== -1;
|
|
3638
|
+
['dragenter', 'dragover'].forEach(ev => zone.addEventListener(ev, (e) => {
|
|
3639
|
+
if (!hasFiles(e)) return;
|
|
3640
|
+
e.preventDefault();
|
|
3641
|
+
zone.classList.add('drag-over');
|
|
3642
|
+
}));
|
|
3643
|
+
zone.addEventListener('dragleave', (e) => {
|
|
3644
|
+
if (e.target === zone) zone.classList.remove('drag-over');
|
|
3645
|
+
});
|
|
3646
|
+
zone.addEventListener('drop', (e) => {
|
|
3647
|
+
zone.classList.remove('drag-over');
|
|
3648
|
+
if (!hasFiles(e)) return;
|
|
3649
|
+
e.preventDefault();
|
|
3650
|
+
const file = e.dataTransfer.files && e.dataTransfer.files[0];
|
|
3651
|
+
if (file) attachDocumentFile(file);
|
|
3652
|
+
});
|
|
3653
|
+
})();
|
|
3654
|
+
|
|
3586
3655
|
function removeAttachedDoc() {
|
|
3587
3656
|
attachedDocFile = null;
|
|
3588
3657
|
attachedDocContent = null;
|
package/static/scripts/graph.js
CHANGED
|
@@ -181,6 +181,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
181
181
|
let rawGraph = { nodes: [], edges: [] };
|
|
182
182
|
let graph = { nodes: [], edges: [] };
|
|
183
183
|
let hiddenTypes = new Set();
|
|
184
|
+
let hiddenEdgeTypes = new Set();
|
|
184
185
|
let selected = null;
|
|
185
186
|
let hovered = null;
|
|
186
187
|
let dragging = null;
|
|
@@ -500,6 +501,21 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
500
501
|
window.runLocalIndex = runLocalIndex;
|
|
501
502
|
window.approveLocalPermission = approveLocalPermission;
|
|
502
503
|
|
|
504
|
+
/* 테마 색상 — CSS 변수에서 캔버스 배경/텍스트를 읽어 다크모드 대응 */
|
|
505
|
+
let themeColors = { bg: '#ffffff', text: '#14162c', surface: '#ffffff' };
|
|
506
|
+
function refreshThemeColors() {
|
|
507
|
+
const cs = getComputedStyle(document.documentElement);
|
|
508
|
+
const read = (name, fallback) => {
|
|
509
|
+
const v = (cs.getPropertyValue(name) || '').trim();
|
|
510
|
+
return v || fallback;
|
|
511
|
+
};
|
|
512
|
+
themeColors = {
|
|
513
|
+
bg: read('--bg', '#ffffff'),
|
|
514
|
+
text: read('--text', '#14162c'),
|
|
515
|
+
surface: read('--surface', read('--surface-2', '#ffffff')),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
503
519
|
function nodeColor(type) {
|
|
504
520
|
return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
|
|
505
521
|
}
|
|
@@ -641,6 +657,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
641
657
|
const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
|
|
642
658
|
graph.edges = rawGraph.edges
|
|
643
659
|
.filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
|
|
660
|
+
.filter(edge => !hiddenEdgeTypes.has(edge.type))
|
|
644
661
|
.map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
|
|
645
662
|
renderFocusChip();
|
|
646
663
|
}
|
|
@@ -808,16 +825,26 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
808
825
|
}
|
|
809
826
|
container.innerHTML = ordered.map(type => {
|
|
810
827
|
const style = edgeStyle(type);
|
|
828
|
+
const checked = hiddenEdgeTypes.has(type) ? '' : 'checked';
|
|
811
829
|
return `
|
|
812
|
-
<
|
|
830
|
+
<label class="filter-item">
|
|
831
|
+
<input type="checkbox" ${checked} onchange="toggleEdgeType(decodeURIComponent('${encodeURIComponent(type)}'), this.checked)">
|
|
813
832
|
<span class="legend-line" style="border-top-color:${style.color}; border-top-width:${Math.max(2, style.width)}px;"></span>
|
|
814
|
-
<span class="
|
|
815
|
-
<span class="
|
|
816
|
-
</
|
|
833
|
+
<span class="filter-name">${escapeHtml(style.label || type)}</span>
|
|
834
|
+
<span class="filter-count">${edgeCounts[type] || 0}</span>
|
|
835
|
+
</label>
|
|
817
836
|
`;
|
|
818
837
|
}).join('');
|
|
819
838
|
}
|
|
820
839
|
|
|
840
|
+
function toggleEdgeType(type, visible) {
|
|
841
|
+
if (visible) hiddenEdgeTypes.delete(type);
|
|
842
|
+
else hiddenEdgeTypes.add(type);
|
|
843
|
+
applyFilter();
|
|
844
|
+
wakeUp();
|
|
845
|
+
}
|
|
846
|
+
window.toggleEdgeType = toggleEdgeType;
|
|
847
|
+
|
|
821
848
|
function toggleType(type, visible) {
|
|
822
849
|
if (visible) hiddenTypes.delete(type);
|
|
823
850
|
else hiddenTypes.add(type);
|
|
@@ -929,6 +956,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
929
956
|
ctx.translate(cam.tx, cam.ty);
|
|
930
957
|
ctx.scale(cam.scale, cam.scale);
|
|
931
958
|
|
|
959
|
+
// LOD: 줌이 너무 작거나 노드가 많으면 레이블 생략 (모바일 성능)
|
|
960
|
+
const showLabels = cam.scale >= 0.5 && graph.nodes.length <= 220;
|
|
961
|
+
|
|
932
962
|
const active = hovered || selected;
|
|
933
963
|
const neighborSet = active ? neighborIds(active) : null;
|
|
934
964
|
|
|
@@ -997,8 +1027,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
997
1027
|
ctx.globalAlpha = alpha;
|
|
998
1028
|
}
|
|
999
1029
|
|
|
1000
|
-
// 레이블
|
|
1001
|
-
{
|
|
1030
|
+
// 레이블 표시 (LOD: 줌이 작거나 노드가 많으면 생략 — 모바일 성능)
|
|
1031
|
+
if (showLabels || isSelected || isHovered || isSearchHit) {
|
|
1002
1032
|
const label = node.title.slice(0, 24);
|
|
1003
1033
|
const fs = Math.max(9.5, 12 / cam.scale);
|
|
1004
1034
|
ctx.font = `600 ${fs}px "SF Pro Display","Inter",system-ui`;
|
|
@@ -1008,8 +1038,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1008
1038
|
const ly = node.y + gap + fs;
|
|
1009
1039
|
const pad = 4 / cam.scale;
|
|
1010
1040
|
const br = 5 / cam.scale;
|
|
1011
|
-
//
|
|
1012
|
-
ctx.
|
|
1041
|
+
// 테마 대응 배경 pill (라이트=흰색, 다크=surface)
|
|
1042
|
+
ctx.globalAlpha = alpha > 0.5 ? alpha * 0.88 : alpha * 0.22;
|
|
1043
|
+
ctx.fillStyle = themeColors.surface;
|
|
1013
1044
|
ctx.beginPath();
|
|
1014
1045
|
if (ctx.roundRect) {
|
|
1015
1046
|
ctx.roundRect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6, br);
|
|
@@ -1017,14 +1048,17 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1017
1048
|
ctx.rect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6);
|
|
1018
1049
|
}
|
|
1019
1050
|
ctx.fill();
|
|
1020
|
-
ctx.
|
|
1051
|
+
ctx.globalAlpha = alpha > 0.5 ? alpha : alpha * 0.3;
|
|
1052
|
+
ctx.fillStyle = themeColors.text;
|
|
1021
1053
|
ctx.fillText(label, lx, ly);
|
|
1054
|
+
ctx.globalAlpha = alpha;
|
|
1022
1055
|
}
|
|
1023
1056
|
|
|
1024
1057
|
ctx.globalAlpha = 1;
|
|
1025
1058
|
});
|
|
1026
1059
|
|
|
1027
1060
|
ctx.restore();
|
|
1061
|
+
drawMinimap();
|
|
1028
1062
|
if (kineticEnergy > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
|
|
1029
1063
|
}
|
|
1030
1064
|
|
|
@@ -1627,10 +1661,134 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
|
|
|
1627
1661
|
});
|
|
1628
1662
|
});
|
|
1629
1663
|
|
|
1630
|
-
|
|
1664
|
+
// 리사이즈/회전/키보드(visualViewport) 시 캔버스 재측정 + 자동 재맞춤
|
|
1665
|
+
// (기존엔 backing store만 리사이즈해서 모바일에서 그래프가 화면 밖으로 나갔음)
|
|
1666
|
+
let resizeFitTimer = null;
|
|
1667
|
+
function handleViewportChange() {
|
|
1631
1668
|
resize();
|
|
1632
1669
|
wakeUp();
|
|
1633
|
-
|
|
1670
|
+
clearTimeout(resizeFitTimer);
|
|
1671
|
+
resizeFitTimer = setTimeout(() => { resize(); fitToScreen(); }, 180);
|
|
1672
|
+
}
|
|
1673
|
+
window.addEventListener('resize', handleViewportChange);
|
|
1674
|
+
window.addEventListener('orientationchange', handleViewportChange);
|
|
1675
|
+
if (window.visualViewport) {
|
|
1676
|
+
window.visualViewport.addEventListener('resize', handleViewportChange);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/* ──────────────────────────────────────────────────────────────────
|
|
1680
|
+
v2.2.1 그래프 1급 UI: 줌 버튼 · 전체화면 · 미니맵 · 카드뷰 · 테마대응
|
|
1681
|
+
────────────────────────────────────────────────────────────────── */
|
|
1682
|
+
// 캔버스가 터치를 직접 소유 (브라우저 기본 제스처와 충돌 방지)
|
|
1683
|
+
if (canvas && canvas.style) canvas.style.touchAction = 'none';
|
|
1684
|
+
|
|
1685
|
+
function zoomBy(factor) {
|
|
1686
|
+
const px = width / 2, py = height / 2;
|
|
1687
|
+
const next = clamp(cam.scale * factor, 0.07, 6);
|
|
1688
|
+
cam.tx = px - (px - cam.tx) * (next / cam.scale);
|
|
1689
|
+
cam.ty = py - (py - cam.ty) * (next / cam.scale);
|
|
1690
|
+
cam.scale = next;
|
|
1691
|
+
wakeUp();
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const stageEl = document.querySelector('.stage');
|
|
1695
|
+
function toggleFullscreen() {
|
|
1696
|
+
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
|
1697
|
+
if (!fsEl && stageEl) {
|
|
1698
|
+
(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen || function () {}).call(stageEl);
|
|
1699
|
+
} else {
|
|
1700
|
+
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
document.addEventListener('fullscreenchange', handleViewportChange);
|
|
1704
|
+
document.addEventListener('webkitfullscreenchange', handleViewportChange);
|
|
1705
|
+
|
|
1706
|
+
// 미니맵 — 전체 노드 개요 + 현재 뷰포트 사각형 (클릭 시 그 지점으로 이동)
|
|
1707
|
+
const minimap = document.getElementById('minimap');
|
|
1708
|
+
const mmCtx = minimap ? minimap.getContext('2d') : null;
|
|
1709
|
+
function drawMinimap() {
|
|
1710
|
+
if (!mmCtx || !minimap || minimap.offsetParent === null) return;
|
|
1711
|
+
const W = minimap.width, H = minimap.height;
|
|
1712
|
+
mmCtx.clearRect(0, 0, W, H);
|
|
1713
|
+
if (!graph.nodes.length) return;
|
|
1714
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
1715
|
+
graph.nodes.forEach(n => { x0 = Math.min(x0, n.x); x1 = Math.max(x1, n.x); y0 = Math.min(y0, n.y); y1 = Math.max(y1, n.y); });
|
|
1716
|
+
const pad = 8, gw = Math.max(1, x1 - x0), gh = Math.max(1, y1 - y0);
|
|
1717
|
+
const s = Math.min((W - pad * 2) / gw, (H - pad * 2) / gh);
|
|
1718
|
+
const ox = pad - x0 * s + (W - pad * 2 - gw * s) / 2;
|
|
1719
|
+
const oy = pad - y0 * s + (H - pad * 2 - gh * s) / 2;
|
|
1720
|
+
graph.nodes.forEach(n => {
|
|
1721
|
+
mmCtx.fillStyle = nodeColor(n.type);
|
|
1722
|
+
mmCtx.beginPath();
|
|
1723
|
+
mmCtx.arc(ox + n.x * s, oy + n.y * s, 1.6, 0, Math.PI * 2);
|
|
1724
|
+
mmCtx.fill();
|
|
1725
|
+
});
|
|
1726
|
+
const vx0 = (0 - cam.tx) / cam.scale, vy0 = (0 - cam.ty) / cam.scale;
|
|
1727
|
+
const vx1 = (width - cam.tx) / cam.scale, vy1 = (height - cam.ty) / cam.scale;
|
|
1728
|
+
mmCtx.strokeStyle = 'rgba(110,74,230,0.95)';
|
|
1729
|
+
mmCtx.lineWidth = 1.2;
|
|
1730
|
+
mmCtx.strokeRect(ox + vx0 * s, oy + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
|
|
1731
|
+
minimap._map = { ox, oy, s };
|
|
1732
|
+
}
|
|
1733
|
+
if (minimap) {
|
|
1734
|
+
minimap.addEventListener('click', (event) => {
|
|
1735
|
+
const m = minimap._map; if (!m) return;
|
|
1736
|
+
const rect = minimap.getBoundingClientRect();
|
|
1737
|
+
const mx = (event.clientX - rect.left) * (minimap.width / rect.width);
|
|
1738
|
+
const my = (event.clientY - rect.top) * (minimap.height / rect.height);
|
|
1739
|
+
cam.tx = width / 2 - ((mx - m.ox) / m.s) * cam.scale;
|
|
1740
|
+
cam.ty = height / 2 - ((my - m.oy) / m.s) * cam.scale;
|
|
1741
|
+
wakeUp();
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// 모바일 카드 뷰 — 노드를 탭 가능한 카드 목록으로 (캔버스가 너무 빽빽할 때)
|
|
1746
|
+
const graphCardList = document.getElementById('graph-card-list');
|
|
1747
|
+
function renderGraphCards() {
|
|
1748
|
+
if (!graphCardList) return;
|
|
1749
|
+
if (!graph.nodes.length) {
|
|
1750
|
+
graphCardList.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
graphCardList.innerHTML = '<div class="search-list">' + graph.nodes.slice(0, 400).map(n => `
|
|
1754
|
+
<button class="search-item" data-node-id="${escapeHtml(n.id)}">
|
|
1755
|
+
<div class="search-item-top">
|
|
1756
|
+
<span class="type-badge" style="background:${nodeColor(n.type)}">${escapeHtml(n.type || '')}</span>
|
|
1757
|
+
<span class="search-item-title">${escapeHtml(n.title || n.id)}</span>
|
|
1758
|
+
</div>
|
|
1759
|
+
${n.summary ? `<p class="search-item-summary">${escapeHtml(n.summary)}</p>` : ''}
|
|
1760
|
+
</button>
|
|
1761
|
+
`).join('') + '</div>';
|
|
1762
|
+
}
|
|
1763
|
+
function toggleGraphCardView() {
|
|
1764
|
+
document.body.classList.toggle('graph-card-view');
|
|
1765
|
+
if (document.body.classList.contains('graph-card-view')) renderGraphCards();
|
|
1766
|
+
}
|
|
1767
|
+
if (graphCardList) {
|
|
1768
|
+
graphCardList.addEventListener('click', (event) => {
|
|
1769
|
+
const target = event.target.closest('[data-node-id]');
|
|
1770
|
+
if (!target) return;
|
|
1771
|
+
const node = graph.nodes.find(n => n.id === target.dataset.nodeId);
|
|
1772
|
+
if (!node) return;
|
|
1773
|
+
document.body.classList.remove('graph-card-view');
|
|
1774
|
+
selected = node;
|
|
1775
|
+
showDetail(node);
|
|
1776
|
+
centerOnNode(node, Math.max(cam.scale, 1));
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// 테마(라이트/다크) 변경 시 캔버스 색상 갱신
|
|
1781
|
+
refreshThemeColors();
|
|
1782
|
+
try {
|
|
1783
|
+
const themeObserver = new MutationObserver(() => { refreshThemeColors(); wakeUp(); });
|
|
1784
|
+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-lt-theme'] });
|
|
1785
|
+
} catch (e) { /* noop */ }
|
|
1786
|
+
|
|
1787
|
+
const bindClick = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); };
|
|
1788
|
+
bindClick('zoom-in-btn', () => zoomBy(1.25));
|
|
1789
|
+
bindClick('zoom-out-btn', () => zoomBy(1 / 1.25));
|
|
1790
|
+
bindClick('fullscreen-btn', toggleFullscreen);
|
|
1791
|
+
bindClick('view-toggle-btn', toggleGraphCardView);
|
|
1634
1792
|
|
|
1635
1793
|
resize();
|
|
1636
1794
|
applyI18n();
|