ltcai 0.2.2 → 0.3.1

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.
@@ -137,6 +137,12 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
137
137
  el.className = 'msg' + (ok ? ' ok' : '');
138
138
  }
139
139
 
140
+ function requestSetupAfterLogin() {
141
+ try {
142
+ sessionStorage.setItem('ltcai_force_setup_after_login', 'true');
143
+ } catch (_) {}
144
+ }
145
+
140
146
  async function doLogin() {
141
147
  const email = document.getElementById('login-email').value.trim();
142
148
  const password = document.getElementById('login-pw').value;
@@ -155,7 +161,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
155
161
  localStorage.setItem('ltcai_user_email', data.email);
156
162
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
157
163
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
158
- window.location.href = '/chat';
164
+ requestSetupAfterLogin();
165
+ window.location.href = '/chat?setup=1';
159
166
  } else {
160
167
  const data = await res.json().catch(() => ({}));
161
168
  setMsg('login-msg', data.detail || t('err_login_fail'));
@@ -197,7 +204,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
197
204
  localStorage.setItem('ltcai_user_email', data.email);
198
205
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
199
206
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
200
- window.location.href = '/chat';
207
+ requestSetupAfterLogin();
208
+ window.location.href = '/chat?setup=1';
201
209
  }
202
210
  });
203
211
  } else {
@@ -1195,4 +1195,300 @@ document.querySelectorAll('[data-export-scope][data-export-format]').forEach(btn
1195
1195
  btn.addEventListener('click', () => exportAdminLogs(btn.dataset.exportScope, btn.dataset.exportFormat));
1196
1196
  });
1197
1197
 
1198
+ // ── Security & Audit Command Center (피드백 #5) ─────────────────────────────
1199
+
1200
+ function ccEscape(value) {
1201
+ if (value === null || value === undefined) return '';
1202
+ const str = String(value);
1203
+ return str
1204
+ .replace(/&/g, '&')
1205
+ .replace(/</g, '&lt;')
1206
+ .replace(/>/g, '&gt;')
1207
+ .replace(/"/g, '&quot;')
1208
+ .replace(/'/g, '&#39;');
1209
+ }
1210
+
1211
+ const CC_CARD_LABELS = {
1212
+ events_today: '오늘 이벤트',
1213
+ high_risk_events: 'High Risk',
1214
+ risky_chats: '위험 채팅',
1215
+ risky_files: '위험 파일',
1216
+ secret_blocks: 'Secret 차단',
1217
+ external_blocks: '외부 전송 차단',
1218
+ admin_raw_views: '관리자 원문 조회',
1219
+ review_required: '검토 필요',
1220
+ };
1221
+
1222
+ let ccUserChart = null;
1223
+ let ccFieldChart = null;
1224
+
1225
+ async function ccFetchJson(path) {
1226
+ try {
1227
+ const res = await apiFetch(path, { headers: adminHeaders() });
1228
+ if (!res.ok) {
1229
+ console.warn('Security CC fetch failed', path, res.status);
1230
+ return null;
1231
+ }
1232
+ return await res.json();
1233
+ } catch (e) {
1234
+ console.warn('Security CC fetch error', path, e);
1235
+ return null;
1236
+ }
1237
+ }
1238
+
1239
+ function renderCcCards(overview) {
1240
+ const root = document.getElementById('security-cc-cards');
1241
+ if (!root || !overview || !overview.cards) return;
1242
+ const html = Object.entries(overview.cards).map(([key, value]) => `
1243
+ <div class="audit-card">
1244
+ <div class="audit-card-label">${ccEscape(CC_CARD_LABELS[key] || key)}</div>
1245
+ <div class="audit-card-value">${ccEscape(value)}</div>
1246
+ </div>
1247
+ `).join('');
1248
+ root.innerHTML = html;
1249
+ }
1250
+
1251
+ function renderCcUsersTable(users) {
1252
+ const wrap = document.getElementById('security-cc-users');
1253
+ if (!wrap) return;
1254
+ if (!users || users.length === 0) {
1255
+ wrap.innerHTML = '<div class="preview" style="padding:14px">표시할 사용자가 없습니다.</div>';
1256
+ return;
1257
+ }
1258
+ const rows = users.slice(0, 25).map(u => `
1259
+ <tr data-cc-user="${ccEscape(u.email)}" style="cursor:pointer">
1260
+ <td>${ccEscape(u.user)}</td>
1261
+ <td>${ccEscape(u.total_chats)}</td>
1262
+ <td style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
1263
+ <td style="color:#b13030">${ccEscape(u.risky_chats)}</td>
1264
+ <td>${ccEscape(u.uploaded_files)}</td>
1265
+ <td style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
1266
+ <td style="color:#b13030">${ccEscape(u.risky_files)}</td>
1267
+ <td>${ccEscape(u.high_risk_events)}</td>
1268
+ <td>${ccEscape(u.risk_rate)}%</td>
1269
+ <td>${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
1270
+ </tr>
1271
+ `).join('');
1272
+ wrap.innerHTML = `
1273
+ <table class="data-table">
1274
+ <thead><tr>
1275
+ <th>사용자</th><th>총 채팅</th><th>준수 채팅</th><th>위험 채팅</th>
1276
+ <th>총 파일</th><th>준수 파일</th><th>위험 파일</th>
1277
+ <th>High</th><th>위험률</th><th>마지막 활동</th>
1278
+ </tr></thead>
1279
+ <tbody>${rows}</tbody>
1280
+ </table>`;
1281
+ wrap.querySelectorAll('tr[data-cc-user]').forEach(tr => {
1282
+ tr.addEventListener('click', () => ccShowUserDrillDown(tr.dataset.ccUser));
1283
+ });
1284
+ }
1285
+
1286
+ function renderCcUserChart(users) {
1287
+ const canvas = document.getElementById('security-cc-user-chart');
1288
+ if (!canvas || typeof Chart === 'undefined') return;
1289
+ const top = users.slice(0, 8);
1290
+ const labels = top.map(u => u.user);
1291
+ if (ccUserChart) { ccUserChart.destroy(); ccUserChart = null; }
1292
+ ccUserChart = new Chart(canvas, {
1293
+ type: 'bar',
1294
+ data: {
1295
+ labels,
1296
+ datasets: [
1297
+ { label: '준수 채팅', backgroundColor: '#5cb874', data: top.map(u => u.compliant_chats) },
1298
+ { label: '위험 채팅', backgroundColor: '#e8636e', data: top.map(u => u.risky_chats) },
1299
+ { label: '준수 파일', backgroundColor: '#7fb5e6', data: top.map(u => u.compliant_files) },
1300
+ { label: '위험 파일', backgroundColor: '#d94c4c', data: top.map(u => u.risky_files) },
1301
+ ]
1302
+ },
1303
+ options: {
1304
+ responsive: true,
1305
+ scales: { x: { stacked: true }, y: { stacked: true } },
1306
+ plugins: { legend: { position: 'bottom' } },
1307
+ }
1308
+ });
1309
+ }
1310
+
1311
+ function renderCcFieldChart(overview) {
1312
+ const canvas = document.getElementById('security-cc-field-chart');
1313
+ const legend = document.getElementById('security-cc-field-legend');
1314
+ if (!canvas || typeof Chart === 'undefined') return;
1315
+ const counts = overview?.field_counts || {};
1316
+ const labels = Object.keys(counts);
1317
+ const data = labels.map(l => counts[l]);
1318
+ if (ccFieldChart) { ccFieldChart.destroy(); ccFieldChart = null; }
1319
+ if (labels.length === 0) {
1320
+ if (legend) legend.textContent = '감지된 민감정보 유형이 없습니다.';
1321
+ return;
1322
+ }
1323
+ ccFieldChart = new Chart(canvas, {
1324
+ type: 'doughnut',
1325
+ data: { labels, datasets: [{ data, backgroundColor: ['#e8636e','#7fb5e6','#f0b14a','#5cb874','#9b6cd0','#3da9b6','#d18cd4','#a3a3a3'] }] },
1326
+ options: { plugins: { legend: { position: 'bottom' } } }
1327
+ });
1328
+ if (legend) {
1329
+ legend.innerHTML = labels.map((l, i) => `${ccEscape(l)}: ${ccEscape(data[i])}`).join(' · ');
1330
+ }
1331
+ }
1332
+
1333
+ async function ccShowUserDrillDown(email) {
1334
+ const data = await ccFetchJson(`/admin/security/events?user=${encodeURIComponent(email)}`);
1335
+ const wrap = document.getElementById('security-cc-timeline');
1336
+ if (!wrap) return;
1337
+ const events = (data && data.events) || [];
1338
+ if (!events.length) {
1339
+ wrap.innerHTML = `<div class="preview" style="padding:14px">${ccEscape(email)} 사용자에 대한 이벤트가 없습니다.</div>`;
1340
+ return;
1341
+ }
1342
+ const rows = events.slice(0, 40).map(e => `
1343
+ <tr>
1344
+ <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1345
+ <td>${ccEscape(e.event_type || '')}</td>
1346
+ <td>${ccEscape(e.sensitivity || 'none')}</td>
1347
+ <td>${ccEscape((e.sensitive_labels || []).join(', '))}</td>
1348
+ <td>${ccEscape((e.content_preview || '').slice(0, 80))}</td>
1349
+ </tr>
1350
+ `).join('');
1351
+ wrap.innerHTML = `
1352
+ <div style="margin-bottom:8px;color:var(--muted-text);font-size:12px">${ccEscape(email)} 사용자의 보안 이벤트 ${events.length}건</div>
1353
+ <table class="data-table">
1354
+ <thead><tr><th>시각</th><th>유형</th><th>민감도</th><th>라벨</th><th>마스킹 preview</th></tr></thead>
1355
+ <tbody>${rows}</tbody>
1356
+ </table>`;
1357
+ }
1358
+
1359
+ async function loadSecurityCommandCenter() {
1360
+ const [overview, usersResp, eventsResp, filesResp] = await Promise.all([
1361
+ ccFetchJson('/admin/security/overview'),
1362
+ ccFetchJson('/admin/security/users'),
1363
+ ccFetchJson('/admin/security/events?limit=50'),
1364
+ ccFetchJson('/admin/security/files'),
1365
+ ]);
1366
+
1367
+ if (overview) {
1368
+ renderCcCards(overview);
1369
+ renderCcFieldChart(overview);
1370
+ }
1371
+ if (usersResp && Array.isArray(usersResp.users)) {
1372
+ renderCcUsersTable(usersResp.users);
1373
+ renderCcUserChart(usersResp.users);
1374
+ }
1375
+ if (eventsResp && Array.isArray(eventsResp.events)) {
1376
+ const chats = eventsResp.events.filter(e => (e.sensitivity || 'none') !== 'none' && e.event_type === 'chat_message').slice(0, 20);
1377
+ const chatWrap = document.getElementById('security-cc-chats');
1378
+ if (chatWrap) {
1379
+ chatWrap.innerHTML = chats.length ? `
1380
+ <table class="data-table">
1381
+ <thead><tr><th>시각</th><th>사용자</th><th>민감도</th><th>라벨</th><th>마스킹 preview</th></tr></thead>
1382
+ <tbody>${chats.map(e => `
1383
+ <tr>
1384
+ <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1385
+ <td>${ccEscape(e.user_nickname || e.user_email || 'Unknown')}</td>
1386
+ <td>${ccEscape(e.sensitivity)}</td>
1387
+ <td>${ccEscape((e.sensitive_labels || []).join(', '))}</td>
1388
+ <td>${ccEscape((e.content_preview || '').slice(0, 100))}</td>
1389
+ </tr>`).join('')}
1390
+ </tbody>
1391
+ </table>` : '<div class="preview" style="padding:14px">감지된 민감 채팅이 없습니다.</div>';
1392
+ }
1393
+ const timelineWrap = document.getElementById('security-cc-timeline');
1394
+ if (timelineWrap && !timelineWrap.querySelector('table')) {
1395
+ const rows = eventsResp.events.slice(0, 30).map(e => `
1396
+ <tr>
1397
+ <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1398
+ <td>${ccEscape(e.event_type || '')}</td>
1399
+ <td>${ccEscape(e.user_nickname || e.user_email || 'Unknown')}</td>
1400
+ <td>${ccEscape(e.sensitivity || 'none')}</td>
1401
+ </tr>
1402
+ `).join('');
1403
+ timelineWrap.innerHTML = rows ? `
1404
+ <table class="data-table">
1405
+ <thead><tr><th>시각</th><th>유형</th><th>사용자</th><th>민감도</th></tr></thead>
1406
+ <tbody>${rows}</tbody>
1407
+ </table>` : '<div class="preview" style="padding:14px">감사 이벤트가 없습니다.</div>';
1408
+ }
1409
+ }
1410
+ if (filesResp && Array.isArray(filesResp.files)) {
1411
+ const files = filesResp.files.filter(f => (f.sensitivity || 'none') !== 'none' || (f.sensitive_labels || []).length > 0).slice(0, 20);
1412
+ const fileWrap = document.getElementById('security-cc-files');
1413
+ if (fileWrap) {
1414
+ fileWrap.innerHTML = files.length ? `
1415
+ <table class="data-table">
1416
+ <thead><tr><th>파일</th><th>업로드 사용자</th><th>민감도</th><th>라벨</th><th>크기</th></tr></thead>
1417
+ <tbody>${files.map(f => `
1418
+ <tr>
1419
+ <td>${ccEscape(f.filename || f.file_id)}</td>
1420
+ <td>${ccEscape(f.user_nickname || f.user_email || 'Unknown')}</td>
1421
+ <td>${ccEscape(f.sensitivity || 'none')}</td>
1422
+ <td>${ccEscape((f.sensitive_labels || []).join(', '))}</td>
1423
+ <td>${ccEscape(f.bytes || '')}</td>
1424
+ </tr>`).join('')}
1425
+ </tbody>
1426
+ </table>` : '<div class="preview" style="padding:14px">위험 등급 파일이 없습니다.</div>';
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ async function ccLoadRaw(scope) {
1432
+ const pre = document.getElementById('security-cc-raw');
1433
+ if (!pre) return;
1434
+ pre.textContent = '불러오는 중...';
1435
+ try {
1436
+ const res = await apiFetch(`/admin/security/raw?scope=${encodeURIComponent(scope)}`, { headers: adminHeaders() });
1437
+ if (!res.ok) { pre.textContent = `요청 실패 (HTTP ${res.status})`; return; }
1438
+ const text = await res.text();
1439
+ try {
1440
+ pre.textContent = JSON.stringify(JSON.parse(text), null, 2);
1441
+ } catch (_) {
1442
+ pre.textContent = text;
1443
+ }
1444
+ } catch (e) {
1445
+ pre.textContent = String(e);
1446
+ }
1447
+ }
1448
+
1449
+ async function ccExport(scope, format) {
1450
+ try {
1451
+ const res = await apiFetch('/admin/security/export', {
1452
+ method: 'POST',
1453
+ headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
1454
+ body: JSON.stringify({ scope, format }),
1455
+ });
1456
+ if (!res.ok) {
1457
+ alert('보안 리포트 추출 실패 (HTTP ' + res.status + ')');
1458
+ return;
1459
+ }
1460
+ const blob = await res.blob();
1461
+ const url = URL.createObjectURL(blob);
1462
+ const a = document.createElement('a');
1463
+ a.href = url;
1464
+ a.download = `security_${scope}.${format === 'xlsx' ? 'xlsx' : format}`;
1465
+ a.click();
1466
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
1467
+ } catch (e) {
1468
+ alert(String(e));
1469
+ }
1470
+ }
1471
+
1472
+ document.getElementById('security-cc-export-toggle')?.addEventListener('click', () => {
1473
+ const opts = document.getElementById('security-cc-export-options');
1474
+ if (opts) opts.classList.toggle('open');
1475
+ });
1476
+ document.querySelectorAll('[data-cc-scope][data-cc-format]').forEach(btn => {
1477
+ btn.addEventListener('click', () => ccExport(btn.dataset.ccScope, btn.dataset.ccFormat));
1478
+ });
1479
+ document.querySelectorAll('[data-cc-raw]').forEach(btn => {
1480
+ btn.addEventListener('click', () => ccLoadRaw(btn.dataset.ccRaw));
1481
+ });
1482
+
1483
+ // 보안 탭 진입 시 자동 로드
1484
+ document.querySelectorAll('[data-admin-nav="security"]').forEach(el => {
1485
+ el.addEventListener('click', () => { setTimeout(loadSecurityCommandCenter, 50); });
1486
+ });
1487
+ // 메뉴 셀렉터를 모를 수도 있으니 hash 변경 시에도 시도
1488
+ window.addEventListener('hashchange', () => {
1489
+ if (location.hash.indexOf('security') >= 0) loadSecurityCommandCenter();
1490
+ });
1491
+
1198
1492
  loadDashboard();
1493
+ // 보안 콘솔도 첫 진입 시 로드 시도 (실패해도 무해)
1494
+ setTimeout(loadSecurityCommandCenter, 600);
@@ -11,6 +11,8 @@ const chatViewport = document.getElementById('chat-viewport');
11
11
  let currentUserNickname = "Guest";
12
12
  let currentUserEmail = "";
13
13
  let isAdmin = false;
14
+ let telegramHistorySyncEnabled = false;
15
+ let telegramHistorySyncInFlight = false;
14
16
 
15
17
  // ── 멀티 LLM 파이프라인 상태 ──
16
18
  let pipelineConfig = { planning: null, executing: null, reviewing: null };
@@ -517,6 +519,32 @@ const chatViewport = document.getElementById('chat-viewport');
517
519
  localStorage.setItem(scopedStorageKey('onboarding_complete'), 'true');
518
520
  }
519
521
 
522
+ function consumeSetupAfterLoginFlag() {
523
+ try {
524
+ const params = new URLSearchParams(window.location.search);
525
+ const value = params.get('setup') === '1' || sessionStorage.getItem('ltcai_force_setup_after_login') === 'true';
526
+ sessionStorage.removeItem('ltcai_force_setup_after_login');
527
+ if (params.get('setup') === '1') {
528
+ const cleanUrl = `${window.location.pathname}${window.location.hash || ''}`;
529
+ window.history.replaceState({}, '', cleanUrl);
530
+ }
531
+ return value;
532
+ } catch (_) {
533
+ return false;
534
+ }
535
+ }
536
+
537
+ async function modelLoadedForChat() {
538
+ try {
539
+ const res = await apiFetch('/health');
540
+ if (!res.ok) return false;
541
+ const data = await res.json();
542
+ return Boolean(data.current_model || (data.loaded_models || []).length);
543
+ } catch (_) {
544
+ return false;
545
+ }
546
+ }
547
+
520
548
  function workspaceLabel(kind) {
521
549
  if (kind === 'company') return currentLang === 'ko' ? '회사 워크스페이스' : 'Company Workspace';
522
550
  return currentLang === 'ko' ? '개인 워크스페이스' : 'Personal Workspace';
@@ -799,14 +827,20 @@ const chatViewport = document.getElementById('chat-viewport');
799
827
  let onboardingRecs = null;
800
828
  let onboardingSelectedModel = null;
801
829
 
802
- function startOnboardingIfNeeded(force = false) {
830
+ async function startOnboardingIfNeeded(force = false) {
803
831
  updateWorkspaceModeUi();
804
- if (!force && onboardingComplete()) {
832
+ const forceAfterLogin = consumeSetupAfterLoginFlag();
833
+ const hasLoadedModel = await modelLoadedForChat();
834
+ if (!force && !forceAfterLogin && onboardingComplete() && hasLoadedModel) {
805
835
  document.getElementById('onboarding-overlay')?.classList.remove('open');
806
836
  return;
807
837
  }
808
838
  document.getElementById('onboarding-overlay')?.classList.add('open');
809
- renderOnboardingWorkspace();
839
+ if (forceAfterLogin || !hasLoadedModel) {
840
+ renderPcAnalysis();
841
+ } else {
842
+ renderOnboardingWorkspace();
843
+ }
810
844
  }
811
845
 
812
846
  function renderOnboardingShell(stepIndex, bodyHtml, actionsHtml = '') {
@@ -1260,7 +1294,7 @@ const chatViewport = document.getElementById('chat-viewport');
1260
1294
  selectMode(mode);
1261
1295
  setOnboardingComplete();
1262
1296
  document.getElementById('onboarding-overlay')?.classList.remove('open');
1263
- showHome();
1297
+ showChat();
1264
1298
  }
1265
1299
 
1266
1300
  function switchAcctTab(tab) {
@@ -1836,7 +1870,17 @@ const chatViewport = document.getElementById('chat-viewport');
1836
1870
  if (!finalData) throw new Error('모델 준비 응답이 비어 있습니다.');
1837
1871
  closeModelPanel();
1838
1872
  await loadModelStatus();
1839
- addMessage('ai', `<b>${escapeHtml(compactModelName(finalData.current || modelId))}</b> 로드 되었습니다.`);
1873
+ // 피드백 #1/#2: 사용자가 클릭한 modelId 아니라 백엔드가 돌려준 current를 신뢰한다.
1874
+ const actualCurrent = finalData.current || (finalData.resolution && finalData.resolution.expected_current) || modelId;
1875
+ window.__latticeActiveModel = actualCurrent;
1876
+ let statusLine = `<b>${escapeHtml(compactModelName(actualCurrent))}</b> 로드 되었습니다.`;
1877
+ if (finalData.ready_to_chat === false) {
1878
+ const reason = (finalData.smoke_test && finalData.smoke_test.reason) || '채팅 호환성 검사 실패';
1879
+ statusLine += `<br><span class="sensitivity-preview">⚠️ 현재 채팅 호환성이 낮습니다 (${escapeHtml(reason)}). 다른 실행 엔진을 추천합니다.</span>`;
1880
+ } else if (finalData.compatibility_status === 'unknown') {
1881
+ statusLine += `<br><span class="sensitivity-preview">호환성 테스트를 완료하지 못했습니다. 채팅이 가능하지만 답변 품질이 일정하지 않을 수 있어요.</span>`;
1882
+ }
1883
+ addMessage('ai', statusLine);
1840
1884
  } catch (e) {
1841
1885
  document.getElementById('model-list').innerHTML = `
1842
1886
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
@@ -1849,6 +1893,19 @@ const chatViewport = document.getElementById('chat-viewport');
1849
1893
  return prepareAndLoadModel(encodedId, engine);
1850
1894
  }
1851
1895
 
1896
+ // 피드백 #1/#2: 사용자가 직접 모델을 선택했을 때도 같은 표준 흐름을 타도록 노출.
1897
+ async function selectModelByCard(card) {
1898
+ if (!card || !card.id) {
1899
+ throw new Error('모델 카드가 비어 있습니다.');
1900
+ }
1901
+ const encoded = encodeURIComponent(card.id);
1902
+ const engine = card.engine || (Array.isArray(card.engine_options) && card.engine_options[0]?.engine) || '';
1903
+ return prepareAndLoadModel(encoded, engine);
1904
+ }
1905
+ if (typeof window !== 'undefined') {
1906
+ window.selectModelByCard = selectModelByCard;
1907
+ }
1908
+
1852
1909
  function fillVpcForm(config) {
1853
1910
  if (!config) return;
1854
1911
  document.getElementById('vpc-provider').value = config.provider || '';
@@ -1938,7 +1995,7 @@ const chatViewport = document.getElementById('chat-viewport');
1938
1995
  if (!t) {
1939
1996
  t = document.createElement('div');
1940
1997
  t.id = 'ltcai-toast';
1941
- t.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:#1e2330;color:#f8fafc;border:1px solid rgba(255,255,255,0.12);border-radius:10px;padding:10px 18px;font-size:13px;font-weight:600;z-index:9999;box-shadow:0 8px 24px rgba(0,0,0,0.4);pointer-events:none;transition:opacity .2s;';
1998
+ t.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(255,255,255,0.96);color:#14162c;border:1px solid rgba(111,66,232,0.16);border-radius:10px;padding:10px 18px;font-size:13px;font-weight:600;z-index:9999;box-shadow:0 8px 24px rgba(88,72,150,0.16);pointer-events:none;transition:opacity .2s;';
1942
1999
  document.body.appendChild(t);
1943
2000
  }
1944
2001
  t.textContent = msg;
@@ -3264,10 +3321,13 @@ const chatViewport = document.getElementById('chat-viewport');
3264
3321
  }
3265
3322
 
3266
3323
  async function syncTelegramHistory() {
3324
+ if (!telegramHistorySyncEnabled || telegramHistorySyncInFlight) return;
3325
+ telegramHistorySyncInFlight = true;
3267
3326
  try {
3268
3327
  const res = await apiFetch('/history');
3269
3328
  if (!res.ok) return;
3270
3329
  const history = await res.json();
3330
+ let added = false;
3271
3331
  for (const item of history) {
3272
3332
  if (item.source !== 'telegram') continue;
3273
3333
  const key = `${item.timestamp || ''}:${item.role}:${item.content}`;
@@ -3278,9 +3338,13 @@ const chatViewport = document.getElementById('chat-viewport');
3278
3338
  ? 'Lattice AI'
3279
3339
  : (item.user_nickname || 'Telegram');
3280
3340
  addMessage(role, item.content || '', null, sender);
3341
+ added = true;
3281
3342
  }
3282
- loadHistory();
3343
+ if (added) loadHistory();
3283
3344
  } catch (e) { }
3345
+ finally {
3346
+ telegramHistorySyncInFlight = false;
3347
+ }
3284
3348
  }
3285
3349
 
3286
3350
  async function sendToAgent(text, extraCtx = '') {
@@ -3866,10 +3930,19 @@ const chatViewport = document.getElementById('chat-viewport');
3866
3930
  const res = await apiFetch('/runtime_features');
3867
3931
  if (res.ok) {
3868
3932
  const f = await res.json();
3933
+ telegramHistorySyncEnabled = Boolean(f.telegram_enabled);
3869
3934
  if (!f.graph_enabled) {
3870
3935
  const btn = document.getElementById('data-graph-btn');
3871
3936
  if (btn) btn.style.display = 'none';
3872
3937
  }
3938
+ return;
3939
+ }
3940
+ } catch (_) {}
3941
+ try {
3942
+ const res = await apiFetch('/health');
3943
+ if (res.ok) {
3944
+ const data = await res.json();
3945
+ telegramHistorySyncEnabled = Boolean(data.features?.telegram_enabled);
3873
3946
  }
3874
3947
  } catch (_) {}
3875
3948
  })();
@@ -3878,7 +3951,7 @@ const chatViewport = document.getElementById('chat-viewport');
3878
3951
  loadVpcStatus();
3879
3952
  restoreCurrentConversation();
3880
3953
  syncTelegramHistory();
3881
- setInterval(syncTelegramHistory, 2500);
3954
+ setInterval(syncTelegramHistory, 15000);
3882
3955
 
3883
3956
  // ── 내 컴퓨터 ──────────────────────────────────────────────────
3884
3957
  let cuAgentRunning = false;
@@ -4370,7 +4443,7 @@ const chatViewport = document.getElementById('chat-viewport');
4370
4443
  function _showComplete() {
4371
4444
  _subtitle('설정 완료!');
4372
4445
  _footInfo('');
4373
- _footBtns(`<button class="wbtn wbtn-primary" onclick="closeSetupWizard();loadModelStatus()">완료 ✓</button>`);
4446
+ _footBtns(`<button class="wbtn wbtn-primary" onclick="closeSetupWizard();loadModelStatus();showChat()">완료 ✓</button>`);
4374
4447
  }
4375
4448
 
4376
4449
  // ── MCP 관리 모달 ────────────────────────────────────────────────────────
@@ -324,8 +324,12 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
324
324
  </div>
325
325
  ${rootRows ? `<div class="local-root-list">${rootRows}</div>` : ''}
326
326
  <div class="local-option-row">
327
- <label><input type="checkbox" ${localState.includeOcr ? 'checked' : ''} onchange="setLocalOption('includeOcr', this.checked)"> ${t('local_ocr')}</label>
328
- <label><input type="checkbox" ${localState.watchEnabled ? 'checked' : ''} onchange="setLocalOption('watchEnabled', this.checked)"> ${t('local_watch')}</label>
327
+ <button type="button" class="local-option-btn ${localState.includeOcr ? 'active' : ''}" onclick="setLocalOption('includeOcr', ${localState.includeOcr ? 'false' : 'true'})" aria-pressed="${localState.includeOcr ? 'true' : 'false'}">
328
+ <i class="ti ti-photo-scan"></i><span>${t('local_ocr')}</span>
329
+ </button>
330
+ <button type="button" class="local-option-btn ${localState.watchEnabled ? 'active' : ''}" onclick="setLocalOption('watchEnabled', ${localState.watchEnabled ? 'false' : 'true'})" aria-pressed="${localState.watchEnabled ? 'true' : 'false'}">
331
+ <i class="ti ti-refresh-dot"></i><span>${t('local_watch')}</span>
332
+ </button>
329
333
  </div>
330
334
  <div class="local-source-actions">
331
335
  <button class="local-source-btn" ${localState.busy ? 'disabled' : ''} onclick="runLocalTree()" title="${t('local_tree')}"><i class="ti ti-folders"></i>${t('local_tree')}</button>
package/static/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Lattice AI Service Worker — enables PWA install on Android/iOS
2
2
  // Strategy: network-first for API, cache-first for static assets.
3
- const CACHE = "ltcai-v6";
3
+ const CACHE = "ltcai-v7";
4
4
  const STATIC = [
5
5
  "/",
6
6
  "/static/lattice-reference.css",