ltcai 2.1.0 → 2.2.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.
Files changed (51) hide show
  1. package/README.md +153 -609
  2. package/auto_setup.py +17 -17
  3. package/docs/CHANGELOG.md +83 -0
  4. package/docs/MULTI_AGENT_RUNTIME.md +4 -4
  5. package/docs/PLUGIN_SDK.md +7 -7
  6. package/docs/REALTIME_COLLABORATION.md +6 -6
  7. package/docs/V2_ARCHITECTURE.md +45 -25
  8. package/docs/WORKFLOW_DESIGNER.md +4 -4
  9. package/docs/architecture.md +127 -135
  10. package/docs/kg-schema.md +3 -3
  11. package/docs/public-deploy.md +2 -3
  12. package/docs/spec-vs-impl.md +13 -10
  13. package/knowledge_graph.py +2 -2
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/models.py +8 -0
  16. package/latticeai/core/config.py +1 -1
  17. package/latticeai/core/graph_curator.py +2 -2
  18. package/latticeai/core/marketplace.py +2 -2
  19. package/latticeai/core/model_compat.py +7 -63
  20. package/latticeai/core/model_resolution.py +1 -1
  21. package/latticeai/core/multi_agent.py +1 -1
  22. package/latticeai/core/plugins.py +1 -1
  23. package/latticeai/core/realtime.py +1 -1
  24. package/latticeai/core/workflow_engine.py +1 -1
  25. package/latticeai/core/workspace_os.py +1 -1
  26. package/latticeai/server_app.py +1 -1
  27. package/latticeai/services/model_catalog.py +105 -153
  28. package/latticeai/services/model_recommendation.py +28 -17
  29. package/latticeai/services/model_runtime.py +2 -2
  30. package/llm_router.py +80 -92
  31. package/ltcai_cli.py +2 -3
  32. package/package.json +8 -3
  33. package/static/account.html +3 -1
  34. package/static/activity.html +5 -2
  35. package/static/admin.html +5 -1
  36. package/static/agents.html +5 -2
  37. package/static/chat.html +12 -10
  38. package/static/css/responsive.css +597 -0
  39. package/static/css/tokens.css +224 -165
  40. package/static/graph.html +12 -2
  41. package/static/lattice-reference.css +366 -739
  42. package/static/platform.css +45 -16
  43. package/static/plugins.html +5 -2
  44. package/static/scripts/admin.js +33 -33
  45. package/static/scripts/chat.js +109 -42
  46. package/static/scripts/graph.js +169 -11
  47. package/static/scripts/ux.js +167 -0
  48. package/static/workflows.html +5 -2
  49. package/static/workspace.css +55 -19
  50. package/static/workspace.html +5 -2
  51. package/telegram_bot.py +1 -1
@@ -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
- --bg: #0f1115;
5
- --panel: #16191f;
6
- --panel-2: #1c2027;
7
- --border: rgba(255, 255, 255, 0.08);
8
- --text: #e7ecf3;
9
- --muted: #94a3b8;
10
- --accent: #378ADD;
11
- --accent-2: #5ea7ec;
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: #fff; letter-spacing: .3px; }
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: #fff; background: var(--panel-2); }
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(300px, 1fr)); }
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: 13px; font-family: inherit;
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: #0b0d11; border: 1px solid var(--border); border-radius: 10px;
69
- padding: 12px; overflow: auto; font-size: 12px; color: #cbd5e1; max-height: 360px;
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 { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2); border: 1px solid var(--border); padding: 12px 16px; border-radius: 10px; font-size: 13px; max-width: 360px; }
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
+ }
@@ -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.0" />
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
- <link rel="stylesheet" href="/static/platform.css" />
7
+ <script src="/static/scripts/ux.js?v=2.2.1"></script>
8
+ <link rel="stylesheet" href="/static/css/tokens.css?v=2.2.1" />
9
+ <link rel="stylesheet" href="/static/platform.css?v=2.2.1" />
10
+ <link rel="stylesheet" href="/static/css/responsive.css?v=2.2.1" />
8
11
  </head>
9
12
  <body>
10
13
  <main>
@@ -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 = `
@@ -227,7 +227,7 @@ const chatViewport = document.getElementById('chat-viewport');
227
227
  my_status: '내 상태 보기', auto_setup: '자동 설정',
228
228
  nav_home: '홈', nav_chat: '채팅', nav_workspace: 'Workspace OS', nav_knowledge: '지식 그래프',
229
229
  nav_pipeline: '파이프라인', nav_files: '내 컴퓨터',
230
- nav_model_status: '모델 상태', nav_runtime: '런타임 설정',
230
+ nav_model_status: '모델 상태', nav_runtime: '실행 방식 설정',
231
231
  nav_advanced_settings: '고급 설정',
232
232
  history_search_ph: '대화 검색...', new_chat: 'New Chat',
233
233
  history_section: '대화', history_empty: '아직 저장된 대화가 없습니다.',
@@ -235,7 +235,7 @@ const chatViewport = document.getElementById('chat-viewport');
235
235
  confirm_delete_chat: '이 대화를 삭제할까요?',
236
236
  home_greeting: '안녕하세요, {name}님',
237
237
  home_greeting_short: '안녕하세요',
238
- ops_ai_model: 'AI 모델', ops_local_runtime: '로컬 런타임',
238
+ ops_ai_model: 'AI 모델', ops_local_runtime: ' 컴퓨터에서 실행',
239
239
  ops_admin_network: '관리자 네트워크', ops_admin_security: '관리자 보안',
240
240
  ops_pipeline_value: '멀티 LLM 파이프라인',
241
241
  ops_pipeline_meta: 'Plan → Execute → Review 모델 설정',
@@ -248,7 +248,7 @@ const chatViewport = document.getElementById('chat-viewport');
248
248
  home_recent_files: '최근 파일', home_open_files: '파일 열기', home_no_files: '파일이 없습니다',
249
249
  chat_intro_title: 'Lattice AI',
250
250
  chat_intro_desc: '로컬 모델, 파일, 지식 그래프, 멀티모달 작업을 한 대화 흐름에서 연결하는 개인 AI 워크스페이스입니다.',
251
- chat_cap_file: '파일 생성', chat_cap_knowledge: '지식 정리', chat_cap_runtime: '로컬 런타임',
251
+ chat_cap_file: '파일 생성', chat_cap_knowledge: '지식 정리', chat_cap_runtime: ' 컴퓨터에서 실행',
252
252
  // 계정 모달
253
253
  tab_profile: '프로필', tab_password: '비밀번호',
254
254
  label_name: '이름', label_nickname: '닉네임',
@@ -283,12 +283,12 @@ const chatViewport = document.getElementById('chat-viewport');
283
283
  mode_default: '기본 모드',
284
284
  mode_default_sub: '대화, 파일 생성, 지식 정리를 한 화면에서',
285
285
  mode_advanced: '고급 모드',
286
- mode_advanced_sub: '모델 상태, 런타임 설정, 고급 설정',
286
+ mode_advanced_sub: '같은 기능을 자세한 설명으로 표시',
287
287
  mode_admin: '관리자 모드',
288
- mode_admin_sub: '운영자용 관리자 대시보드',
288
+ mode_admin_sub: '사용자, 정책, 감사 로그 관리',
289
289
  // 패널 제목
290
290
  model_switcher: '모델 스위처',
291
- model_switcher_sub: '실행 엔진을 설치하고, 엔진에 맞는 local/cloud LLM을 선택합니다.',
291
+ model_switcher_sub: '제작 국가, 제작 회사, 실행 방식, 인터넷 사용 여부를 확인하고 모델을 선택합니다.',
292
292
  // 권한 다이얼로그
293
293
  perm_title: '파일 접근 요청', btn_deny: '거부', btn_allow: '허용',
294
294
  },
@@ -306,7 +306,7 @@ const chatViewport = document.getElementById('chat-viewport');
306
306
  my_status: 'My Status', auto_setup: 'Auto Setup',
307
307
  nav_home: 'Home', nav_chat: 'Chat', nav_workspace: 'Workspace OS', nav_knowledge: 'Knowledge Graph',
308
308
  nav_pipeline: 'Pipeline', nav_files: 'My Computer',
309
- nav_model_status: 'Model Status', nav_runtime: 'Runtime Settings',
309
+ nav_model_status: 'Model Status', nav_runtime: 'Execution Settings',
310
310
  nav_advanced_settings: 'Advanced Settings',
311
311
  history_search_ph: 'Search chats...', new_chat: 'New Chat',
312
312
  history_section: 'Chats', history_empty: 'No saved chats yet.',
@@ -314,7 +314,7 @@ const chatViewport = document.getElementById('chat-viewport');
314
314
  confirm_delete_chat: 'Delete this chat?',
315
315
  home_greeting: 'Hello, {name}',
316
316
  home_greeting_short: 'Hello',
317
- ops_ai_model: 'AI model', ops_local_runtime: 'Local runtime',
317
+ ops_ai_model: 'AI model', ops_local_runtime: 'Runs on this computer',
318
318
  ops_admin_network: 'Admin Network', ops_admin_security: 'Admin Security',
319
319
  ops_pipeline_value: 'Multi-LLM Pipeline',
320
320
  ops_pipeline_meta: 'Plan → Execute → Review model setup',
@@ -327,7 +327,7 @@ const chatViewport = document.getElementById('chat-viewport');
327
327
  home_recent_files: 'Recent Files', home_open_files: 'Open Files', home_no_files: 'No files yet',
328
328
  chat_intro_title: 'Lattice AI',
329
329
  chat_intro_desc: 'A personal AI workspace that connects local models, files, knowledge graphs, and multimodal work in one conversation flow.',
330
- chat_cap_file: 'File creation', chat_cap_knowledge: 'Knowledge organizing', chat_cap_runtime: 'Local runtime',
330
+ chat_cap_file: 'File creation', chat_cap_knowledge: 'Knowledge organizing', chat_cap_runtime: 'Runs on this computer',
331
331
  // Account modal
332
332
  tab_profile: 'Profile', tab_password: 'Password',
333
333
  label_name: 'Name', label_nickname: 'Nickname',
@@ -367,7 +367,7 @@ const chatViewport = document.getElementById('chat-viewport');
367
367
  mode_admin_sub: 'Admin dashboard for operators',
368
368
  // Panel titles
369
369
  model_switcher: 'Model Switcher',
370
- model_switcher_sub: 'Install a runtime engine and select a local/cloud LLM.',
370
+ model_switcher_sub: 'Check maker country, maker company, execution method, internet use, then select a model.',
371
371
  // Permission dialog
372
372
  perm_title: 'File Access Request', btn_deny: 'Deny', btn_allow: 'Allow',
373
373
  }
@@ -650,11 +650,8 @@ const chatViewport = document.getElementById('chat-viewport');
650
650
  }
651
651
 
652
652
  async function _loadHomeDashboard() {
653
- const mode = getCurrentMode();
654
-
655
- // 자동 설정 카드: 고급/관리자 모드만
656
653
  const setupCard = document.getElementById('home-setup-card');
657
- if (setupCard) setupCard.style.display = (mode === 'advanced' || mode === 'admin') ? 'flex' : 'none';
654
+ if (setupCard) setupCard.style.display = 'flex';
658
655
 
659
656
  // 모델 + sysinfo 병렬 fetch
660
657
  try {
@@ -953,7 +950,7 @@ const chatViewport = document.getElementById('chat-viewport');
953
950
  const selected = models.find(item => item.checked && !item.disabled && (item.model_id || item.action?.model_id))
954
951
  || models.find(item => !item.disabled && (item.model_id || item.action?.model_id));
955
952
  const zero = onboardingRecs?.summary?.zero_config || onboardingEnv?.zero_config?.recommend || {};
956
- const modelId = selected?.model_id || selected?.action?.model_id || zero.model_id || 'mlx-community/Llama-3.2-3B-Instruct-4bit';
953
+ const modelId = selected?.model_id || selected?.action?.model_id || zero.model_id || 'mlx-community/gemma-4-12b-it-4bit';
957
954
  const engineItem = (onboardingRecs?.engines || []).find(item => item.checked && !item.disabled);
958
955
  const runtime = engineItem?.name || (zero.runtime === 'mlx' ? 'MLX' : zero.runtime) || 'MLX';
959
956
  return {
@@ -1058,15 +1055,22 @@ const chatViewport = document.getElementById('chat-viewport');
1058
1055
  <div style="font-weight:700">⭐ Best for this PC — ${escapeHtml(top.name || top.id)} ${badge('recommended')}</div>
1059
1056
  <div style="font-size:12px;opacity:0.8;margin-top:3px">${escapeHtml(top.reason || '')}</div>
1060
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>` : ''}
1061
1059
  </div>` : '';
1062
1060
 
1063
1061
  const rows = families.map((fam) => {
1064
1062
  const best = fam.best;
1065
- const items = (fam.models || []).map((m) => `
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}">
1067
- <span>${escapeHtml(m.name || m.id)}</span>
1068
- <span style="white-space:nowrap">${escapeHtml(m.size || '')} · ${escapeHtml(ram(m))} ${badge(m.status)}</span>
1069
- </div>`).join('');
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('');
1070
1074
  return `
1071
1075
  <details style="margin:6px 0;border:1px solid var(--border,#e5e7eb);border-radius:8px;padding:8px 10px">
1072
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>
@@ -1351,7 +1355,7 @@ const chatViewport = document.getElementById('chat-viewport');
1351
1355
  <button class="onboarding-mode" onclick="finishOnboarding('advanced')">
1352
1356
  <i class="ti ti-terminal-2"></i>
1353
1357
  <h3>고급 모드</h3>
1354
- <p>모델 상태, 런타임 설정, 고급 설정까지 함께 다룹니다.</p>
1358
+ <p>같은 기능을 유지하면서 모델, 메모리, 실행 방식 설명을 더 자세히 표시합니다.</p>
1355
1359
  </button>
1356
1360
  ${adminCard}
1357
1361
  </div>
@@ -1528,7 +1532,7 @@ const chatViewport = document.getElementById('chat-viewport');
1528
1532
  const isUnavailable = unsupported || (!isLocalEngine && engineMissing) || keyMissing || verifyFailed;
1529
1533
  const badge = unsupported ? '현재 환경 미지원'
1530
1534
  : engineMissing && isLocalEngine ? '설치 후 자동 로드'
1531
- : engineMissing ? '엔진 설치 필요'
1535
+ : engineMissing ? '실행 도구 설치 필요'
1532
1536
  : needsPull ? '다운로드 후 자동 로드'
1533
1537
  : keyMissing ? `필요: ${model.requires || 'API key'}`
1534
1538
  : verifyFailed ? `실패: ${model.verify_reason || '검증 실패'}`
@@ -1539,29 +1543,67 @@ const chatViewport = document.getElementById('chat-viewport');
1539
1543
  const action = isLocalEngine
1540
1544
  ? `selectModelByCard('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`
1541
1545
  : `loadSelectedModel('${encodeURIComponent(model.id)}', '${engine?.id || ''}')`;
1546
+ const chipsHtml = modelSourceChipsHtml(model);
1547
+ const detailLine = chipsHtml ? `${model.id} · ${badge}` : `${model.id} · ${badge}`;
1542
1548
  return `
1543
1549
  <button class="model-option${cls}" ${isUnavailable ? 'disabled' : ''} onclick="${action}">
1544
1550
  <div>
1545
1551
  <strong>${escapeHtml(model.name || compactModelName(model.id))}</strong>
1546
- <span>${escapeHtml(model.id)} · ${escapeHtml(badge)}</span>
1552
+ ${chipsHtml}
1553
+ <span>${escapeHtml(detailLine)}</span>
1547
1554
  </div>
1548
1555
  <i class="ti ${icon}"></i>
1549
1556
  </button>
1550
1557
  `;
1551
1558
  }
1552
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
+
1553
1598
  function normalizedFamily(model) {
1554
1599
  const raw = `${model?.family || ''} ${model?.name || ''} ${model?.id || ''}`.toLowerCase();
1555
1600
  if (raw.includes('gpt')) return 'GPT';
1556
1601
  if (raw.includes('claude')) return 'Claude';
1557
1602
  if (raw.includes('grok')) return 'Grok';
1558
1603
  if (raw.includes('gemini')) return 'Gemini';
1559
- if (raw.includes('mistral') || raw.includes('mixtral')) return 'Mistral';
1560
1604
  if (raw.includes('qwen')) return 'Qwen';
1561
1605
  if (raw.includes('llama')) return 'Llama';
1562
1606
  if (raw.includes('gemma')) return 'Gemma';
1563
- if (raw.includes('phi')) return 'Phi';
1564
- if (raw.includes('deepseek')) return 'DeepSeek';
1565
1607
  return (model?.family || '기타');
1566
1608
  }
1567
1609
 
@@ -1642,17 +1684,17 @@ const chatViewport = document.getElementById('chat-viewport');
1642
1684
  const cloudEngines = cachedEngineList.filter(engine => engine.kind === 'cloud');
1643
1685
  const isLocal = modelPanelFilter === 'local';
1644
1686
  const target = isLocal ? localEngines : cloudEngines;
1645
- const emptyText = isLocal ? '등록된 로컬 엔진이 없습니다.' : '등록된 클라우드 엔진이 없습니다.';
1687
+ const emptyText = isLocal ? ' 컴퓨터에서 실행할 수 있는 항목이 없습니다.' : '인터넷 연결 사용할 수 있는 항목이 없습니다.';
1646
1688
 
1647
1689
  modelList.innerHTML = `
1648
- <div class="model-group-title">EXECUTION ENGINES</div>
1690
+ <div class="model-group-title">실행 방식</div>
1649
1691
  <div class="model-filter">
1650
- <button class="model-filter-btn ${isLocal ? 'active' : ''}" onclick="setModelPanelFilter('local')">Local LLM</button>
1651
- <button class="model-filter-btn ${!isLocal ? 'active' : ''}" onclick="setModelPanelFilter('cloud')">Cloud LLM</button>
1692
+ <button class="model-filter-btn ${isLocal ? 'active' : ''}" onclick="setModelPanelFilter('local')">내 컴퓨터에서만 실행</button>
1693
+ <button class="model-filter-btn ${!isLocal ? 'active' : ''}" onclick="setModelPanelFilter('cloud')">인터넷 연결 후 사용</button>
1652
1694
  </div>
1653
1695
  ${!isLocal ? `
1654
1696
  <div style="display:flex;justify-content:flex-end;margin:-2px 0 8px;">
1655
- <button class="admin-action" onclick="verifyCloudModels(true)"><i class="ti ti-activity"></i> Cloud 실사용 테스트</button>
1697
+ <button class="admin-action" onclick="verifyCloudModels(true)"><i class="ti ti-activity"></i> 인터넷 모델 실사용 테스트</button>
1656
1698
  </div>
1657
1699
  ` : ''}
1658
1700
  ${target.length ? target.map(engineCardHtml).join('') : `<div class="sensitivity-preview">${emptyText}</div>`}
@@ -1680,7 +1722,7 @@ const chatViewport = document.getElementById('chat-viewport');
1680
1722
 
1681
1723
  async function verifyCloudModels(force = true) {
1682
1724
  const modelList = document.getElementById('model-list');
1683
- modelList.innerHTML = `<div class="sensitivity-preview">Cloud 모델 실사용 테스트 중입니다... (provider별로 수 초~수십 초)</div>`;
1725
+ modelList.innerHTML = `<div class="sensitivity-preview">인터넷 모델 실사용 테스트 중입니다... (연결 방식별로 수 초~수십 초)</div>`;
1684
1726
  try {
1685
1727
  const res = await apiFetch('/engines/verify-cloud', {
1686
1728
  method: 'POST',
@@ -1688,9 +1730,9 @@ const chatViewport = document.getElementById('chat-viewport');
1688
1730
  body: JSON.stringify({ force })
1689
1731
  });
1690
1732
  const data = await res.json();
1691
- if (!res.ok) throw new Error(data.detail || 'Cloud 실사용 테스트 실패');
1733
+ if (!res.ok) throw new Error(data.detail || '인터넷 모델 실사용 테스트 실패');
1692
1734
  await openModelPanel();
1693
- addMessage('ai', `Cloud 모델 실사용 테스트를 완료했습니다. 실패한 모델은 잠금 상태로 표시됩니다.`);
1735
+ addMessage('ai', `인터넷 모델 실사용 테스트를 완료했습니다. 실패한 모델은 잠금 상태로 표시됩니다.`);
1694
1736
  } catch (e) {
1695
1737
  modelList.innerHTML = `
1696
1738
  <div class="sensitivity-preview">${escapeHtml(e.message)}</div>
@@ -1832,7 +1874,7 @@ const chatViewport = document.getElementById('chat-viewport');
1832
1874
  </div>
1833
1875
  </div>
1834
1876
  <div id="model-download-detail" class="model-download-detail">
1835
- 엔진 설치, 모델 다운로드, 서버 시작, 로드까지 자동으로 진행합니다. 첫 실행은 수 분이 걸릴 수 있습니다.
1877
+ 실행 도구 설치, 모델 다운로드, 연결 준비, 로드까지 자동으로 진행합니다. 첫 실행은 수 분이 걸릴 수 있습니다.
1836
1878
  </div>
1837
1879
  </div>
1838
1880
  `;
@@ -3566,10 +3608,18 @@ const chatViewport = document.getElementById('chat-viewport');
3566
3608
 
3567
3609
  function attachDocument(input) {
3568
3610
  const file = input.files[0];
3611
+ if (!file) return;
3612
+ attachDocumentFile(file);
3613
+ input.value = '';
3614
+ }
3615
+
3616
+ // 파일을 직접 첨부 (드래그앤드롭 / 붙여넣기 / 파일 선택 공용 경로)
3617
+ function attachDocumentFile(file) {
3569
3618
  if (!file) return;
3570
3619
  attachedDocFile = file;
3571
3620
  attachedDocContent = null;
3572
3621
  const row = document.getElementById('attach-preview-row');
3622
+ if (!row) return;
3573
3623
  row.style.display = 'flex';
3574
3624
  row.innerHTML = `
3575
3625
  <div class="attach-chip">
@@ -3578,9 +3628,30 @@ const chatViewport = document.getElementById('chat-viewport');
3578
3628
  <button onclick="removeAttachedDoc()" title="제거">×</button>
3579
3629
  </div>
3580
3630
  <span style="font-size:11px;color:var(--muted);align-self:center">첨부됨 — 전송 시 AI가 파일을 읽습니다</span>`;
3581
- input.value = '';
3582
3631
  }
3583
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
+
3584
3655
  function removeAttachedDoc() {
3585
3656
  attachedDocFile = null;
3586
3657
  attachedDocContent = null;
@@ -4363,7 +4434,7 @@ const chatViewport = document.getElementById('chat-viewport');
4363
4434
  const keys = env.api_keys || {};
4364
4435
 
4365
4436
  const mlxLabel = mlx.available
4366
- ? (mlx.mlx_lm && mlx.mlx_vlm ? 'MLX-LM · MLX-VLM 설치됨' : mlx.mlx_lm ? 'MLX-LM 설치됨' : '부분 설치')
4437
+ ? (mlx.mlx_vlm ? 'MLX-VLM 설치됨' : 'MLX 설치됨 · MLX-VLM 필요')
4367
4438
  : '미설치';
4368
4439
 
4369
4440
  const cloudKeys = Object.entries(keys).filter(([,v]) => v).map(([k]) => k.toUpperCase());
@@ -4378,7 +4449,7 @@ const chatViewport = document.getElementById('chat-viewport');
4378
4449
  { icon: mlx.available ? '✅' : '⚠️', label: 'MLX', value: mlxLabel, ok: mlx.available },
4379
4450
  { icon: tools.ollama ? '✅' : '○', label: 'Ollama', value: tools.ollama ? '설치됨' : '미설치', ok: true },
4380
4451
  { icon: tools.brew ? '✅' : '○', label: 'Homebrew', value: tools.brew ? '설치됨' : '미설치', ok: true },
4381
- { icon: cloudKeys.length ? '✅' : '○', label: 'Cloud API',
4452
+ { icon: cloudKeys.length ? '✅' : '○', label: '인터넷 AI',
4382
4453
  value: cloudKeys.length ? cloudKeys.join(', ') : '없음', ok: true },
4383
4454
  { icon: env.os === 'Darwin' ? '🍎' : '🐧',
4384
4455
  label: '운영체제',
@@ -4607,10 +4678,6 @@ const chatViewport = document.getElementById('chat-viewport');
4607
4678
  let _mcpCurrentTab = 'registry';
4608
4679
 
4609
4680
  async function openMcpModal() {
4610
- if (getCurrentMode() === 'default') {
4611
- showToast('고급 모드에서 사용할 수 있습니다.');
4612
- return;
4613
- }
4614
4681
  document.getElementById('mcp-modal-overlay').classList.add('open');
4615
4682
  await renderMcpModal(_mcpCurrentTab);
4616
4683
  }