ltcai 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1555,6 +1555,91 @@
1555
1555
  line-height: 1.5;
1556
1556
  }
1557
1557
 
1558
+ .lattice-ref-admin .enterprise-grid {
1559
+ display: grid;
1560
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1561
+ gap: 10px;
1562
+ }
1563
+
1564
+ .lattice-ref-admin .enterprise-cap-card {
1565
+ display: flex;
1566
+ align-items: center;
1567
+ gap: 10px;
1568
+ min-width: 0;
1569
+ border: 1px solid rgba(111,66,232,0.12);
1570
+ border-radius: 8px;
1571
+ background: rgba(255,255,255,0.70);
1572
+ padding: 11px 12px;
1573
+ }
1574
+
1575
+ .lattice-ref-admin .enterprise-cap-card i {
1576
+ color: #7a74a0;
1577
+ font-size: 18px;
1578
+ }
1579
+
1580
+ .lattice-ref-admin .enterprise-cap-card.on i {
1581
+ color: #0d8f72;
1582
+ }
1583
+
1584
+ .lattice-ref-admin .enterprise-cap-card span {
1585
+ flex: 1;
1586
+ min-width: 0;
1587
+ color: #14162c;
1588
+ font-size: 13px;
1589
+ font-weight: 800;
1590
+ overflow: hidden;
1591
+ text-overflow: ellipsis;
1592
+ white-space: nowrap;
1593
+ text-transform: capitalize;
1594
+ }
1595
+
1596
+ .lattice-ref-admin .enterprise-cap-card strong {
1597
+ color: #4a4668;
1598
+ font-size: 11px;
1599
+ }
1600
+
1601
+ .lattice-ref-admin .enterprise-kv {
1602
+ display: grid;
1603
+ gap: 8px;
1604
+ }
1605
+
1606
+ .lattice-ref-admin .enterprise-kv div {
1607
+ display: grid;
1608
+ grid-template-columns: 150px minmax(0, 1fr);
1609
+ gap: 10px;
1610
+ align-items: start;
1611
+ border: 1px solid rgba(111,66,232,0.10);
1612
+ border-radius: 8px;
1613
+ background: rgba(255,255,255,0.62);
1614
+ padding: 9px 10px;
1615
+ }
1616
+
1617
+ .lattice-ref-admin .enterprise-kv span {
1618
+ color: #4a4668;
1619
+ font-size: 12px;
1620
+ font-weight: 800;
1621
+ }
1622
+
1623
+ .lattice-ref-admin .enterprise-kv strong {
1624
+ color: #14162c;
1625
+ font-size: 12px;
1626
+ line-height: 1.45;
1627
+ overflow-wrap: anywhere;
1628
+ }
1629
+
1630
+ .lattice-ref-admin .enterprise-json {
1631
+ max-height: 280px;
1632
+ overflow: auto;
1633
+ margin: 12px 0 0;
1634
+ border: 1px solid rgba(111,66,232,0.10);
1635
+ border-radius: 8px;
1636
+ background: rgba(20,22,44,0.05);
1637
+ color: #14162c;
1638
+ padding: 12px;
1639
+ font-size: 12px;
1640
+ white-space: pre-wrap;
1641
+ }
1642
+
1558
1643
  @media (max-width: 980px) {
1559
1644
  .lattice-ref-chat .reference-card-grid,
1560
1645
  .reference-lists {
@@ -2784,9 +2869,11 @@ body.lattice-ref-graph {
2784
2869
  top: 16px;
2785
2870
  right: 16px;
2786
2871
  display: flex;
2872
+ flex-wrap: wrap;
2787
2873
  gap: 8px;
2788
2874
  padding: 8px;
2789
2875
  border-radius: 10px;
2876
+ max-width: min(760px, calc(100% - 32px));
2790
2877
  }
2791
2878
 
2792
2879
  .tb-btn {
@@ -3301,6 +3388,103 @@ body.lattice-ref-graph {
3301
3388
 
3302
3389
  .jump-btn:hover { filter: brightness(1.04); }
3303
3390
 
3391
+ .jump-btn.secondary {
3392
+ border: 1px solid rgba(111,66,232,0.20);
3393
+ color: var(--text);
3394
+ background: rgba(255,255,255,0.82);
3395
+ box-shadow: none;
3396
+ cursor: pointer;
3397
+ font: inherit;
3398
+ font-size: 12px;
3399
+ font-weight: 700;
3400
+ }
3401
+
3402
+ .detail-actions {
3403
+ display: flex;
3404
+ flex-wrap: wrap;
3405
+ gap: 8px;
3406
+ margin-bottom: 14px;
3407
+ }
3408
+
3409
+ .related-node-list {
3410
+ display: grid;
3411
+ gap: 7px;
3412
+ margin-bottom: 14px;
3413
+ }
3414
+
3415
+ .related-node-btn {
3416
+ width: 100%;
3417
+ min-width: 0;
3418
+ display: grid;
3419
+ grid-template-columns: 18px minmax(0, 1fr) auto;
3420
+ align-items: center;
3421
+ gap: 8px;
3422
+ border: 1px solid rgba(111,66,232,0.13);
3423
+ border-radius: 8px;
3424
+ background: rgba(255,255,255,0.76);
3425
+ color: var(--text);
3426
+ padding: 8px 10px;
3427
+ text-align: left;
3428
+ cursor: pointer;
3429
+ }
3430
+
3431
+ .related-node-btn:hover {
3432
+ border-color: rgba(111,66,232,0.34);
3433
+ background: rgba(111,66,232,0.07);
3434
+ }
3435
+
3436
+ .related-node-btn strong {
3437
+ min-width: 0;
3438
+ overflow: hidden;
3439
+ text-overflow: ellipsis;
3440
+ white-space: nowrap;
3441
+ font-size: 12px;
3442
+ }
3443
+
3444
+ .related-node-btn em {
3445
+ color: var(--faint);
3446
+ font-size: 11px;
3447
+ font-style: normal;
3448
+ white-space: nowrap;
3449
+ }
3450
+
3451
+ .focus-chip {
3452
+ position: absolute;
3453
+ z-index: 21;
3454
+ left: 16px;
3455
+ bottom: 16px;
3456
+ max-width: min(560px, calc(100% - 32px));
3457
+ display: flex;
3458
+ flex-wrap: wrap;
3459
+ gap: 8px;
3460
+ padding: 8px;
3461
+ border: 1px solid var(--line);
3462
+ border-radius: 10px;
3463
+ background: rgba(255,255,255,0.90);
3464
+ box-shadow: var(--shadow);
3465
+ backdrop-filter: blur(18px);
3466
+ }
3467
+
3468
+ .focus-chip span {
3469
+ display: inline-flex;
3470
+ align-items: center;
3471
+ gap: 6px;
3472
+ border-radius: 999px;
3473
+ background: rgba(111,66,232,0.08);
3474
+ color: var(--text);
3475
+ padding: 5px 10px;
3476
+ font-size: 11px;
3477
+ font-weight: 800;
3478
+ max-width: 260px;
3479
+ overflow: hidden;
3480
+ text-overflow: ellipsis;
3481
+ white-space: nowrap;
3482
+ }
3483
+
3484
+ .search-shell.search-open .search-results {
3485
+ display: block;
3486
+ }
3487
+
3304
3488
  .empty-hint {
3305
3489
  margin: 0;
3306
3490
  color: var(--muted);
@@ -49,6 +49,7 @@ const A18N = {
49
49
  nav_users: '사용자 관리',
50
50
  nav_permissions: '권한 관리',
51
51
  nav_sso: 'SSO 관리',
52
+ nav_enterprise: 'Enterprise',
52
53
  nav_security: '보안 모니터링',
53
54
  nav_audit: '감사 로그',
54
55
  nav_chat: '채팅으로',
@@ -170,6 +171,16 @@ const A18N = {
170
171
  section_sensitivity: '보안 모니터링',
171
172
  section_audit: '감사 로그',
172
173
  section_sso: 'SSO 관리',
174
+ enterprise_title: 'Enterprise 관리자',
175
+ enterprise_desc: '관리자 정책, 감사 추출, SIEM 추출, 조직 설정, 기능 상태를 확인합니다.',
176
+ enterprise_policies: '관리자 정책',
177
+ enterprise_policies_desc: 'Community 유효 정책과 Enterprise 정책 팩 상태입니다.',
178
+ enterprise_org: '조직 설정',
179
+ enterprise_org_desc: '워크스페이스 거버넌스와 조직 기능 상태입니다.',
180
+ enterprise_audit_export: '감사 추출',
181
+ enterprise_audit_export_desc: 'Community에서는 로컬 추출이 가능하며 보존 정책은 Enterprise 확장 지점입니다.',
182
+ enterprise_siem: 'SIEM 추출',
183
+ enterprise_siem_desc: 'Community에서 외부 이벤트를 전송하지 않고 SIEM envelope를 미리 봅니다.',
173
184
  },
174
185
  en: {
175
186
  admin_sub: 'Admin Dashboard',
@@ -180,6 +191,7 @@ const A18N = {
180
191
  nav_users: 'User Management',
181
192
  nav_permissions: 'Permission Management',
182
193
  nav_sso: 'SSO Management',
194
+ nav_enterprise: 'Enterprise',
183
195
  nav_security: 'Security Monitoring',
184
196
  nav_audit: 'Audit Logs',
185
197
  nav_chat: 'Back to Chat',
@@ -301,6 +313,16 @@ const A18N = {
301
313
  section_sensitivity: 'Security monitoring',
302
314
  section_audit: 'Audit logs',
303
315
  section_sso: 'SSO management',
316
+ enterprise_title: 'Enterprise Admin',
317
+ enterprise_desc: 'Review admin policies, audit export, SIEM export, organization settings, and capability status.',
318
+ enterprise_policies: 'Admin Policies',
319
+ enterprise_policies_desc: 'Effective Community policy and Enterprise policy-pack status.',
320
+ enterprise_org: 'Organization Settings',
321
+ enterprise_org_desc: 'Workspace governance and organization capability status.',
322
+ enterprise_audit_export: 'Audit Export',
323
+ enterprise_audit_export_desc: 'Community local export is available; retention is an Enterprise extension point.',
324
+ enterprise_siem: 'SIEM Export',
325
+ enterprise_siem_desc: 'Preview the SIEM envelope without streaming external events in Community.',
304
326
  }
305
327
  };
306
328
 
@@ -310,6 +332,7 @@ let latestUsers = [];
310
332
  let latestSso = null;
311
333
  let latestSensitivity = null;
312
334
  let latestAudit = null;
335
+ let latestEnterprise = null;
313
336
 
314
337
  function t(key) {
315
338
  return (A18N[currentLang] || A18N.ko)[key] || key;
@@ -352,6 +375,7 @@ function setLang(lang) {
352
375
  renderSso(latestSso);
353
376
  renderSensitivity(latestSensitivity);
354
377
  renderAudit(latestAudit);
378
+ renderEnterpriseAdmin(latestEnterprise);
355
379
  loadDashboard();
356
380
  }
357
381
 
@@ -788,6 +812,97 @@ function renderAudit(audit) {
788
812
  ` : `<div class="preview" style="padding:14px">${t('audit_no_events')}</div>`;
789
813
  }
790
814
 
815
+ function enterpriseStatusTag(label, enabled) {
816
+ return `<span class="tag ${enabled ? 'low' : 'medium'}">${esc(label)}: ${enabled ? 'enabled' : 'disabled'}</span>`;
817
+ }
818
+
819
+ function renderKeyValues(targetId, rows) {
820
+ const target = document.getElementById(targetId);
821
+ if (!target) return;
822
+ target.innerHTML = `
823
+ <div class="enterprise-kv">
824
+ ${rows.map(([label, value]) => `
825
+ <div>
826
+ <span>${esc(label)}</span>
827
+ <strong>${esc(value)}</strong>
828
+ </div>
829
+ `).join('')}
830
+ </div>
831
+ `;
832
+ }
833
+
834
+ function renderEnterpriseAdmin(payload) {
835
+ latestEnterprise = payload || null;
836
+ const enterprise = payload || {};
837
+ const edition = enterprise.edition || {};
838
+ const caps = edition.capabilities || {};
839
+ const tags = document.getElementById('enterprise-status-tags');
840
+ if (tags) {
841
+ tags.innerHTML = [
842
+ enterpriseStatusTag('edition', Boolean(edition.is_enterprise)),
843
+ enterpriseStatusTag('policy packs', Boolean(enterprise.admin_policies?.enabled)),
844
+ enterpriseStatusTag('siem', Boolean(enterprise.siem_export?.enabled)),
845
+ ].join('');
846
+ }
847
+
848
+ const grid = document.getElementById('enterprise-capability-status');
849
+ if (grid) {
850
+ const entries = Object.keys(caps).length ? Object.entries(caps) : [];
851
+ grid.innerHTML = entries.length ? entries.map(([name, enabled]) => `
852
+ <div class="enterprise-cap-card ${enabled ? 'on' : 'off'}">
853
+ <i class="ti ${enabled ? 'ti-circle-check' : 'ti-lock'}"></i>
854
+ <span>${esc(name.replaceAll('_', ' '))}</span>
855
+ <strong>${enabled ? 'enabled' : 'disabled'}</strong>
856
+ </div>
857
+ `).join('') : `<div class="preview" style="padding:14px">Capability status unavailable.</div>`;
858
+ }
859
+
860
+ const policies = enterprise.admin_policies || {};
861
+ renderKeyValues('enterprise-admin-policies', [
862
+ ['Capability', policies.capability || 'admin_policy_packs'],
863
+ ['Enabled', Boolean(policies.enabled)],
864
+ ['Enforced', Boolean(policies.enforced)],
865
+ ['Base roles', (policies.effective_policy?.base_roles || []).join(', ')],
866
+ ['Local file access', policies.effective_policy?.local_file_access || 'approval-token gated'],
867
+ ['Package install', policies.effective_policy?.package_install || 'admin-only'],
868
+ ['Note', policies.note || 'Community features remain available.'],
869
+ ]);
870
+
871
+ const org = enterprise.organization_settings || {};
872
+ renderKeyValues('enterprise-org-settings', [
873
+ ['Workspaces', (org.community_baseline?.workspaces || []).join(', ')],
874
+ ['Roles', (org.community_baseline?.roles || []).join(', ')],
875
+ ['Data isolation', org.community_baseline?.data_isolation || 'single-tenant local storage'],
876
+ ['Governance enabled', Object.values(org.governance_capabilities || {}).filter(Boolean).length],
877
+ ['Note', org.note || 'Enterprise governance is an extension point.'],
878
+ ]);
879
+
880
+ const audit = enterprise.audit_export || {};
881
+ renderKeyValues('enterprise-audit-export', [
882
+ ['Local export', audit.local_export?.available ? 'available' : 'unavailable'],
883
+ ['Endpoint', audit.local_export?.endpoint || '/admin/security/export'],
884
+ ['Formats', (audit.local_export?.formats || []).join(', ')],
885
+ ['SIEM streaming', audit.siem_streaming?.enabled ? 'enabled' : 'disabled'],
886
+ ['Retention', audit.compliance_retention?.enabled ? 'enabled' : 'disabled'],
887
+ ]);
888
+
889
+ const siem = enterprise.siem_export || {};
890
+ renderKeyValues('enterprise-siem-export', [
891
+ ['Capability', siem.capability || 'siem_export'],
892
+ ['Enabled', Boolean(siem.enabled)],
893
+ ['Streamed', Boolean(siem.streamed)],
894
+ ['Destination', siem.destination || 'not configured'],
895
+ ]);
896
+ const preview = document.getElementById('enterprise-siem-preview');
897
+ if (preview) preview.textContent = JSON.stringify(siem.preview_envelope || {}, null, 2);
898
+ }
899
+
900
+ async function refreshSiemPreview() {
901
+ const res = await apiFetch('/admin/enterprise/siem-export', { headers: adminHeaders() });
902
+ const data = res.ok ? await res.json() : {};
903
+ renderEnterpriseAdmin({ ...(latestEnterprise || {}), siem_export: data });
904
+ }
905
+
791
906
  function cellValue(value) {
792
907
  if (value === null || value === undefined) return '';
793
908
  if (Array.isArray(value)) return value.map(cellValue).filter(Boolean).join('; ');
@@ -1074,7 +1189,7 @@ async function loadDashboard() {
1074
1189
  access.style.display = 'none';
1075
1190
 
1076
1191
  try {
1077
- const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes, ssoRes] = await Promise.all([
1192
+ const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes, ssoRes, enterpriseRes] = await Promise.all([
1078
1193
  apiFetch('/health'),
1079
1194
  apiFetch('/vpc/status'),
1080
1195
  apiFetch('/admin/summary', { headers: adminHeaders() }),
@@ -1084,6 +1199,7 @@ async function loadDashboard() {
1084
1199
  apiFetch('/admin/stats', { headers: adminHeaders() }),
1085
1200
  apiFetch('/admin/audit', { headers: adminHeaders() }),
1086
1201
  apiFetch('/admin/sso', { headers: adminHeaders() }),
1202
+ apiFetch('/admin/enterprise', { headers: adminHeaders() }),
1087
1203
  ]);
1088
1204
 
1089
1205
  const health = healthRes.ok ? await healthRes.json() : null;
@@ -1095,6 +1211,7 @@ async function loadDashboard() {
1095
1211
  const stats = statsRes.ok ? await statsRes.json() : null;
1096
1212
  const audit = auditRes.ok ? await auditRes.json() : null;
1097
1213
  const sso = ssoRes.ok ? await ssoRes.json() : null;
1214
+ const enterprise = enterpriseRes.ok ? await enterpriseRes.json() : null;
1098
1215
 
1099
1216
  renderSummary(health, summary, vpc);
1100
1217
  fillVpcForm(vpc);
@@ -1103,6 +1220,7 @@ async function loadDashboard() {
1103
1220
  renderSensitivity(sensitivity);
1104
1221
  renderAudit(audit);
1105
1222
  renderSso(sso);
1223
+ renderEnterpriseAdmin(enterprise);
1106
1224
 
1107
1225
  if (invite) {
1108
1226
  document.getElementById('invite-link-input').value = invite.invite_url;
@@ -1118,6 +1236,7 @@ async function loadDashboard() {
1118
1236
  if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
1119
1237
  if (!auditRes.ok) failedSections.push(t('section_audit'));
1120
1238
  if (!ssoRes.ok) failedSections.push(t('section_sso'));
1239
+ if (!enterpriseRes.ok) failedSections.push('Enterprise');
1121
1240
 
1122
1241
  if (failedSections.length) {
1123
1242
  access.style.display = 'block';
@@ -1189,6 +1308,7 @@ document.getElementById('save-sso-btn').addEventListener('click', saveSso);
1189
1308
  document.getElementById('test-sso-btn').addEventListener('click', () => {
1190
1309
  window.location.href = `${API_BASE}/auth/sso/login`;
1191
1310
  });
1311
+ document.getElementById('refresh-siem-btn')?.addEventListener('click', () => refreshSiemPreview().catch(e => alert(String(e))));
1192
1312
  document.getElementById('security-export-toggle')?.addEventListener('click', () => toggleExportOptions('security'));
1193
1313
  document.getElementById('audit-export-toggle')?.addEventListener('click', () => toggleExportOptions('audit'));
1194
1314
  document.querySelectorAll('[data-export-scope][data-export-format]').forEach(btn => {
@@ -1033,6 +1033,7 @@ const chatViewport = document.getElementById('chat-viewport');
1033
1033
  const data = await res.json();
1034
1034
  if (!res.ok) return;
1035
1035
  const rec = (data && data.recommendations) || {};
1036
+ const profile = (data && data.profile) || {};
1036
1037
  const families = rec.families || [];
1037
1038
  if (!families.length) return;
1038
1039
  const counts = rec.counts || {};
@@ -1043,25 +1044,45 @@ const chatViewport = document.getElementById('chat-viewport');
1043
1044
  not_recommended: ['권장 안 함', '#9ca3af'],
1044
1045
  };
1045
1046
  const [label, color] = map[status] || ['', '#9ca3af'];
1046
- return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:11px;color:#fff;background:${color}">${label}</span>`;
1047
+ return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;color:#fff;background:${color}">${label}</span>`;
1047
1048
  };
1049
+ const ram = (m) => (m.required_ram_gb != null) ? `~${m.required_ram_gb}GB RAM (est.)` : '';
1050
+ const nextStep = (engine) => engine === 'ollama'
1051
+ ? 'Next: ollama pull'
1052
+ : engine === 'local_mlx' ? 'Next: download & load' : 'Next: connect engine';
1053
+
1054
+ // Top pick callout
1055
+ const top = rec.top_pick;
1056
+ const topHtml = top ? `
1057
+ <div style="border:1px solid #16a34a;background:#f0fdf4;border-radius:10px;padding:10px 12px;margin:8px 0">
1058
+ <div style="font-weight:700">⭐ Best for this PC — ${escapeHtml(top.name || top.id)} ${badge('recommended')}</div>
1059
+ <div style="font-size:12px;opacity:0.8;margin-top:3px">${escapeHtml(top.reason || '')}</div>
1060
+ <div style="font-size:12px;margin-top:4px">${escapeHtml(top.size || '')} · ${escapeHtml(ram(top))} · ${escapeHtml(nextStep(rec.engine))}</div>
1061
+ </div>` : '';
1062
+
1048
1063
  const rows = families.map((fam) => {
1049
1064
  const best = fam.best;
1050
1065
  const items = (fam.models || []).map((m) => `
1051
- <div style="display:flex;justify-content:space-between;gap:8px;padding:2px 0;font-size:12px;opacity:${m.status === 'not_recommended' ? 0.55 : 1}">
1066
+ <div style="display:flex;justify-content:space-between;gap:8px;padding:3px 0;font-size:12px;opacity:${m.status === 'not_recommended' ? 0.55 : 1}">
1052
1067
  <span>${escapeHtml(m.name || m.id)}</span>
1053
- <span>${escapeHtml(m.size || '')} ${badge(m.status)}</span>
1068
+ <span style="white-space:nowrap">${escapeHtml(m.size || '')} · ${escapeHtml(ram(m))} ${badge(m.status)}</span>
1054
1069
  </div>`).join('');
1055
1070
  return `
1056
1071
  <details style="margin:6px 0;border:1px solid var(--border,#e5e7eb);border-radius:8px;padding:8px 10px">
1057
- <summary style="cursor:pointer;font-weight:600">${escapeHtml(fam.family)} ${best ? badge(best.status) : ''}</summary>
1072
+ <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>
1058
1073
  <div style="margin-top:6px">${items}</div>
1059
1074
  </details>`;
1060
1075
  }).join('');
1076
+
1077
+ const engineLabel = rec.engine === 'local_mlx' ? 'MLX (Apple Silicon)' : rec.engine;
1078
+ const machine = `${profile.os || ''} · RAM ${rec.ram_gb || '?'}GB · ${rec.apple_silicon ? 'Apple Silicon' : (profile.gpu && profile.gpu.vendor) || 'CPU'} · engine ${engineLabel}`;
1061
1079
  container.innerHTML = `
1062
- <h3 style="margin:14px 0 4px">이 PC에서 실행 가능한 로컬 모델</h3>
1063
- <p style="font-size:12px;opacity:0.7;margin:0 0 8px">RAM ${escapeHtml(String(rec.ram_gb || '?'))}GB 기준 · 추천 ${counts.recommended || 0} · 실행 가능 ${counts.compatible || 0} · 권장 안 함 ${counts.not_recommended || 0}</p>
1064
- ${rows}`;
1080
+ <h3 style="margin:14px 0 4px">이 PC 맞는 로컬 모델</h3>
1081
+ <p style="font-size:12px;opacity:0.7;margin:0 0 4px">${escapeHtml(machine)}</p>
1082
+ <p style="font-size:12px;opacity:0.7;margin:0 0 6px">${badge('recommended')} ${counts.recommended || 0} · ${badge('compatible')} ${counts.compatible || 0} · ${badge('not_recommended')} ${counts.not_recommended || 0} · estimates are conservative, verify before loading</p>
1083
+ ${topHtml}
1084
+ ${rows}
1085
+ <p style="font-size:12px;opacity:0.65;margin:8px 0 0">로컬 모델이 부족하면 클라우드 모델(OpenAI·OpenRouter·Groq 등, API 키 필요)을 선택할 수 있습니다.</p>`;
1065
1086
  } catch (e) {
1066
1087
  /* best-effort enhancement; never break onboarding */
1067
1088
  }