ltcai 0.1.4 → 0.1.8

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/static/chat.html CHANGED
@@ -3,8 +3,19 @@
3
3
 
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
7
7
  <title>Lattice AI — All-in-One Multimodal Workspace</title>
8
+
9
+ <!-- PWA -->
10
+ <link rel="manifest" href="/manifest.json">
11
+ <meta name="theme-color" content="#2d5a3d">
12
+ <meta name="mobile-web-app-capable" content="yes">
13
+ <meta name="apple-mobile-web-app-capable" content="yes">
14
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15
+ <meta name="apple-mobile-web-app-title" content="LatticeAI">
16
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
17
+ <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
18
+
8
19
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
9
20
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
10
21
  <style>
@@ -104,7 +115,7 @@
104
115
 
105
116
  .app-layout {
106
117
  display: flex;
107
- height: 100vh;
118
+ height: 100dvh;
108
119
  width: 100vw;
109
120
  }
110
121
 
@@ -315,6 +326,11 @@
315
326
  box-shadow: 0 0 16px rgba(34,211,160,0.12);
316
327
  }
317
328
 
329
+ .sidebar-primary-actions {
330
+ padding: 8px 10px 10px;
331
+ border-bottom: 1px solid rgba(255,255,255,0.05);
332
+ }
333
+
318
334
  .admin-btn {
319
335
  width: 100%;
320
336
  padding: 9px 12px;
@@ -859,9 +875,51 @@
859
875
  border: 1px solid var(--border);
860
876
  }
861
877
 
878
+ /* ── 사이드바 오버레이 (모바일 드로어 배경) ── */
879
+ .sidebar-overlay {
880
+ display: none;
881
+ position: fixed;
882
+ inset: 0;
883
+ background: rgba(0,0,0,0.5);
884
+ z-index: 99;
885
+ backdrop-filter: blur(2px);
886
+ }
887
+ body.sidebar-open .sidebar-overlay { display: block; }
888
+
889
+ /* ── 햄버거 버튼 ── */
890
+ .sidebar-toggle {
891
+ display: none;
892
+ background: transparent;
893
+ border: none;
894
+ color: var(--text);
895
+ font-size: 20px;
896
+ cursor: pointer;
897
+ padding: 4px 6px;
898
+ border-radius: 6px;
899
+ line-height: 1;
900
+ flex-shrink: 0;
901
+ }
902
+ .sidebar-toggle:hover { background: rgba(255,255,255,0.08); }
903
+
904
+ /* ── 사이드바 닫기 버튼 (모바일 전용) ── */
905
+ .sidebar-close {
906
+ display: none;
907
+ margin-left: auto;
908
+ background: transparent;
909
+ border: none;
910
+ color: var(--faint);
911
+ font-size: 18px;
912
+ cursor: pointer;
913
+ padding: 4px 6px;
914
+ border-radius: 6px;
915
+ line-height: 1;
916
+ }
917
+ .sidebar-close:hover { color: var(--text); }
918
+
862
919
  /* ── 입력창 ── */
863
920
  .input-area {
864
921
  padding: 14px 20px 20px;
922
+ padding-bottom: max(20px, env(safe-area-inset-bottom));
865
923
  background: linear-gradient(0deg, rgba(24,35,50,0.96) 0%, transparent 100%);
866
924
  }
867
925
 
@@ -2166,97 +2224,107 @@
2166
2224
  background: var(--muted);
2167
2225
  }
2168
2226
 
2227
+ /* ── 태블릿 (≤900px) ── */
2169
2228
  @media (max-width: 900px) {
2170
- body {
2171
- overflow: hidden;
2172
- }
2229
+ .status-pill.hide-mobile { display: none; }
2173
2230
 
2174
- .app-layout {
2175
- flex-direction: column;
2176
- }
2177
-
2178
- .sidebar {
2179
- width: 100%;
2180
- min-width: 0;
2181
- max-height: 132px;
2182
- border-right: none;
2183
- border-bottom: 1px solid var(--border);
2184
- }
2185
-
2186
- .sidebar-header {
2187
- padding: 12px 14px;
2188
- }
2189
-
2190
- .user-strip,
2191
- .history-container {
2192
- display: none;
2231
+ .ops-strip {
2232
+ width: calc(100% - 28px);
2233
+ grid-template-columns: 1fr;
2234
+ margin-top: 12px;
2193
2235
  }
2194
2236
 
2195
- .sidebar-footer {
2196
- padding: 0 14px 12px;
2197
- border-top: none;
2198
- }
2237
+ .bubble { max-width: 92%; }
2199
2238
 
2200
- .chat-header {
2201
- align-items: flex-start;
2202
- padding: 10px 14px;
2203
- }
2239
+ .empty-grid { grid-template-columns: 1fr; }
2240
+ .empty-state { margin-top: 4vh; }
2204
2241
 
2205
- .header-pills {
2206
- gap: 6px;
2207
- }
2242
+ .admin-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2243
+ .sensitivity-grid { grid-template-columns: 1fr; }
2244
+ .admin-form-grid { grid-template-columns: 1fr; }
2245
+ .mcp-list { grid-template-columns: 1fr; }
2208
2246
 
2209
- .status-pill.hide-mobile {
2210
- display: none;
2247
+ .admin-table {
2248
+ display: block;
2249
+ overflow-x: auto;
2250
+ white-space: nowrap;
2211
2251
  }
2252
+ }
2212
2253
 
2254
+ /* ── 모바일 드로어 (≤768px) ── */
2255
+ @media (max-width: 768px) {
2256
+ /* ops-strip: 가로 스크롤 한 줄로 압축 */
2213
2257
  .ops-strip {
2214
- width: calc(100% - 28px);
2215
- grid-template-columns: 1fr;
2216
- margin-top: 12px;
2258
+ display: flex;
2259
+ flex-direction: row;
2260
+ overflow-x: auto;
2261
+ gap: 8px;
2262
+ width: calc(100% - 24px);
2263
+ margin: 10px auto 0;
2264
+ scrollbar-width: none;
2265
+ -ms-overflow-style: none;
2217
2266
  }
2218
-
2219
- .messages-viewport {
2220
- padding: 20px 14px 14px;
2267
+ .ops-strip::-webkit-scrollbar { display: none; }
2268
+ .ops-card {
2269
+ flex: 0 0 auto;
2270
+ min-width: 160px;
2271
+ padding: 10px 12px;
2272
+ font-size: 12px;
2221
2273
  }
2222
-
2223
- .bubble {
2224
- max-width: 92%;
2274
+ .ops-label { font-size: 9px; }
2275
+ .ops-value { font-size: 13px; }
2276
+ .ops-meta { font-size: 10px; }
2277
+ .ops-icon { font-size: 18px; }
2278
+ body { overflow: hidden; }
2279
+
2280
+ .sidebar-toggle { display: flex; align-items: center; justify-content: center; }
2281
+ .sidebar-close { display: flex; align-items: center; justify-content: center; }
2282
+
2283
+ /* 사이드바: fixed 드로어로 전환 */
2284
+ .sidebar,
2285
+ .app-layout .sidebar {
2286
+ position: fixed;
2287
+ top: 0;
2288
+ left: 0;
2289
+ height: 100dvh;
2290
+ width: 280px;
2291
+ min-width: 0;
2292
+ z-index: 100;
2293
+ transform: translateX(-100%);
2294
+ transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
2295
+ border-right: 1px solid var(--border-strong);
2296
+ box-shadow: 4px 0 32px rgba(0,0,0,0.5);
2297
+ background: #141715;
2298
+ backdrop-filter: none;
2299
+ -webkit-backdrop-filter: none;
2225
2300
  }
2226
-
2227
- .input-area {
2228
- padding: 12px 14px 14px;
2301
+ body.sidebar-open .sidebar {
2302
+ transform: translateX(0);
2229
2303
  }
2230
2304
 
2231
- .empty-grid {
2232
- grid-template-columns: 1fr;
2233
- }
2305
+ .sidebar-header { padding: 14px 14px; }
2306
+ .sidebar-footer { padding: 10px 14px 14px; border-top: 1px solid var(--border); }
2234
2307
 
2235
- .empty-state {
2236
- margin-top: 4vh;
2237
- }
2308
+ /* 메인 채팅은 항상 전체 너비 */
2309
+ .main-chat { width: 100%; flex: 1; }
2238
2310
 
2239
- .admin-stats {
2240
- grid-template-columns: repeat(2, minmax(0, 1fr));
2241
- }
2311
+ .chat-header { padding: 10px 14px; }
2312
+ .header-pills { gap: 6px; }
2242
2313
 
2243
- .sensitivity-grid {
2244
- grid-template-columns: 1fr;
2245
- }
2246
-
2247
- .admin-form-grid {
2248
- grid-template-columns: 1fr;
2249
- }
2314
+ .messages-viewport { padding: 16px 12px 12px; }
2315
+ .input-area { padding: 10px 12px max(14px, env(safe-area-inset-bottom)); }
2250
2316
 
2251
- .mcp-list {
2252
- grid-template-columns: 1fr;
2253
- }
2317
+ .bubble { max-width: 94%; }
2318
+ }
2254
2319
 
2255
- .admin-table {
2256
- display: block;
2257
- overflow-x: auto;
2258
- white-space: nowrap;
2259
- }
2320
+ /* ── 폰 (≤480px) ── */
2321
+ @media (max-width: 480px) {
2322
+ .bubble { max-width: 98%; font-size: 14px; }
2323
+ .sender-label { font-size: 11px; }
2324
+ textarea { font-size: 16px; } /* iOS 자동 줌 방지 */
2325
+ .model-badge span { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2326
+ .header-pills .status-pill { display: none; }
2327
+ .admin-stats { grid-template-columns: 1fr; }
2260
2328
  }
2261
2329
  </style>
2262
2330
 
@@ -2701,6 +2769,14 @@
2701
2769
  box-shadow: inset -1px 0 0 rgba(255,255,255,0.025);
2702
2770
  }
2703
2771
 
2772
+ @media (max-width: 768px) {
2773
+ .app-layout .sidebar {
2774
+ background: #141715;
2775
+ backdrop-filter: none;
2776
+ -webkit-backdrop-filter: none;
2777
+ }
2778
+ }
2779
+
2704
2780
  .app-layout .sidebar-header,
2705
2781
  .app-layout .user-strip,
2706
2782
  .app-layout .sidebar-footer {
@@ -2909,6 +2985,7 @@
2909
2985
 
2910
2986
 
2911
2987
  <div class="app-layout">
2988
+ <div class="sidebar-overlay" onclick="closeSidebar()"></div>
2912
2989
  <!-- Sidebar -->
2913
2990
  <aside class="sidebar">
2914
2991
  <div class="sidebar-header">
@@ -2917,6 +2994,7 @@
2917
2994
  <div class="brand-title">Lattice AI</div>
2918
2995
  <div class="brand-subtitle">Local MLX Workspace</div>
2919
2996
  </div>
2997
+ <button class="sidebar-close" onclick="closeSidebar()" title="닫기"><i class="ti ti-x"></i></button>
2920
2998
  </div>
2921
2999
  <div class="user-strip">
2922
3000
  <div class="user-avatar" id="user-avatar-initial">G</div>
@@ -2931,15 +3009,18 @@
2931
3009
  <input type="text" id="history-search-input" placeholder="대화 검색..." oninput="onHistorySearch(this.value)">
2932
3010
  </div>
2933
3011
  </div>
3012
+ <div class="sidebar-primary-actions">
3013
+ <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
3014
+ </div>
2934
3015
  <div class="history-container" id="history-container">
2935
3016
  <!-- History items -->
2936
3017
  </div>
2937
3018
  <div class="sidebar-footer">
3019
+ <button id="data-graph-btn" class="new-chat-btn" onclick="openDataGraph()"><i class="ti ti-chart-dots-3"></i> Data Graph</button>
2938
3020
  <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> <span data-i18n="admin_dashboard">관리자 대시보드</span></button>
2939
3021
  <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> <span data-i18n="my_status">내 상태 보기</span></button>
2940
3022
  <button id="setup-wizard-btn" class="setup-wizard-sidebar-btn" onclick="openSetupWizard()"><i class="ti ti-sparkles"></i> <span data-i18n="auto_setup">자동 설정</span></button>
2941
3023
  <button class="setup-wizard-sidebar-btn" onclick="openMcpModal()"><i class="ti ti-plug-connected"></i> MCP 관리</button>
2942
- <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
2943
3024
  </div>
2944
3025
  </aside>
2945
3026
 
@@ -2947,6 +3028,7 @@
2947
3028
  <main class="main-chat">
2948
3029
  <header class="chat-header">
2949
3030
  <div class="header-left">
3031
+ <button class="sidebar-toggle" onclick="toggleSidebar()" title="메뉴"><i class="ti ti-menu-2"></i></button>
2950
3032
  <div class="model-badge">
2951
3033
  <div class="status-dot"></div>
2952
3034
  <span>Gemma-4 Multimodal Agent</span>
@@ -3148,22 +3230,58 @@
3148
3230
  </section>
3149
3231
  </div>
3150
3232
 
3233
+ <!-- ── 파일 에디터 ── -->
3234
+ <div id="file-editor-overlay" class="admin-overlay" style="display:none">
3235
+ <section class="admin-panel" style="max-width:720px;height:85vh;display:flex;flex-direction:column">
3236
+ <div class="admin-header" style="flex-shrink:0">
3237
+ <div style="min-width:0;flex:1">
3238
+ <h2><i class="ti ti-file-pencil" style="color:var(--accent)"></i> 파일 편집</h2>
3239
+ <div id="editor-filepath" style="color:var(--faint);font-size:11px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></div>
3240
+ </div>
3241
+ <button class="admin-close" onclick="closeFileEditor()"><i class="ti ti-x"></i></button>
3242
+ </div>
3243
+ <div style="flex:1;display:flex;flex-direction:column;padding:0 20px 16px;min-height:0">
3244
+ <textarea id="file-editor-content"
3245
+ style="flex:1;width:100%;background:rgba(15,18,15,0.8);border:1px solid var(--border);border-radius:8px;
3246
+ color:var(--text);font-family:monospace;font-size:13px;line-height:1.6;
3247
+ padding:14px;resize:none;outline:none;margin-top:12px"
3248
+ spellcheck="false"></textarea>
3249
+ <div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
3250
+ <button class="admin-action" onclick="saveLocalFile()" style="flex:1">
3251
+ <i class="ti ti-device-floppy"></i> 저장
3252
+ </button>
3253
+ <button class="admin-action" onclick="sendFileToChat()" style="flex:1;background:rgba(34,211,160,0.15);border-color:rgba(34,211,160,0.3)">
3254
+ <i class="ti ti-send"></i> AI에게 보내기
3255
+ </button>
3256
+ <button class="status-btn" onclick="closeFileEditor();document.getElementById('local-browser-overlay').style.display='flex'" style="flex:1">
3257
+ <i class="ti ti-arrow-left"></i> 탐색기로
3258
+ </button>
3259
+ </div>
3260
+ <div id="editor-status" style="font-size:12px;color:var(--accent);margin-top:8px;min-height:18px;text-align:center"></div>
3261
+ </div>
3262
+ </section>
3263
+ </div>
3264
+
3151
3265
  <!-- ── 로컬 파일 브라우저 ── -->
3152
3266
  <div id="local-browser-overlay" class="admin-overlay" style="display:none">
3153
- <section class="admin-panel" style="max-width:540px">
3267
+ <section class="admin-panel" style="max-width:560px">
3154
3268
  <div class="admin-header">
3155
- <div>
3156
- <h2><i class="ti ti-folder-open" style="color:var(--accent)"></i> 로컬 파일 접근</h2>
3157
- <p style="color:var(--muted);font-size:12px;margin-top:4px">경로를 입력하면 AI가 권한을 요청 후 접근합니다.</p>
3269
+ <div style="flex:1;min-width:0">
3270
+ <h2><i class="ti ti-folder-open" style="color:var(--accent)"></i> 로컬 파일</h2>
3271
+ <div id="local-breadcrumb" style="color:var(--faint);font-size:11px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">~</div>
3158
3272
  </div>
3159
3273
  <button class="admin-close" onclick="closeLocalBrowser()"><i class="ti ti-x"></i></button>
3160
3274
  </div>
3161
- <div class="admin-body">
3162
- <div style="display:flex;gap:8px;margin-bottom:14px">
3163
- <input id="local-path-input" class="admin-input" style="flex:1" placeholder="/Users/parktaesoo/Downloads" value="">
3164
- <button class="admin-action" onclick="browseLocalPath()"><i class="ti ti-folder-search"></i> 탐색</button>
3275
+ <div class="admin-body" style="padding-top:10px">
3276
+ <!-- 즐겨찾기 -->
3277
+ <div id="local-favorites" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
3278
+ <button class="status-btn" style="font-size:12px;padding:5px 10px" onclick="localNav('~')"><i class="ti ti-home"></i> 홈</button>
3279
+ <button class="status-btn" style="font-size:12px;padding:5px 10px" onclick="localNav('~/Downloads')"><i class="ti ti-download"></i> 다운로드</button>
3280
+ <button class="status-btn" style="font-size:12px;padding:5px 10px" onclick="localNav('~/Desktop')"><i class="ti ti-device-desktop"></i> 데스크탑</button>
3281
+ <button class="status-btn" style="font-size:12px;padding:5px 10px" onclick="localNav('~/Documents')"><i class="ti ti-files"></i> 문서</button>
3282
+ <button class="status-btn" style="font-size:12px;padding:5px 10px" onclick="localNavUp()"><i class="ti ti-arrow-up"></i> 위로</button>
3165
3283
  </div>
3166
- <div id="local-browser-result" style="min-height:60px"></div>
3284
+ <div id="local-browser-result" style="min-height:80px;max-height:420px;overflow-y:auto"></div>
3167
3285
  </div>
3168
3286
  </section>
3169
3287
  </div>
@@ -3420,7 +3538,10 @@
3420
3538
  }
3421
3539
 
3422
3540
  function apiFetch(path, options = {}) {
3423
- return fetch(`${API_BASE}${path}`, options);
3541
+ const headers = { ...(options.headers || {}) };
3542
+ const token = localStorage.getItem('ltcai_session_token') || '';
3543
+ if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
3544
+ return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
3424
3545
  }
3425
3546
 
3426
3547
  function createConversationId() {
@@ -3450,9 +3571,21 @@
3450
3571
  localStorage.removeItem('ltcai_user_email');
3451
3572
  localStorage.removeItem('ltcai_user_nickname');
3452
3573
  localStorage.removeItem('ltcai_is_admin');
3574
+ localStorage.removeItem('ltcai_session_token');
3453
3575
  window.location.href = '/account';
3454
3576
  }
3455
3577
 
3578
+ function toggleSidebar() {
3579
+ document.body.classList.toggle('sidebar-open');
3580
+ }
3581
+ function closeSidebar() {
3582
+ document.body.classList.remove('sidebar-open');
3583
+ }
3584
+
3585
+ function openDataGraph() {
3586
+ window.location.href = `${API_BASE}/graph`;
3587
+ }
3588
+
3456
3589
  const I18N = {
3457
3590
  ko: {
3458
3591
  // 인증
@@ -3690,9 +3823,11 @@
3690
3823
  }
3691
3824
 
3692
3825
  function adminHeaders() {
3826
+ const token = localStorage.getItem('ltcai_session_token') || '';
3693
3827
  return {
3694
3828
  'Content-Type': 'application/json',
3695
- 'X-Admin-Email': currentUserEmail
3829
+ 'X-Admin-Email': currentUserEmail,
3830
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
3696
3831
  };
3697
3832
  }
3698
3833
 
@@ -4069,7 +4204,13 @@
4069
4204
  showToast(currentLang === 'ko' ? '관리자 권한이 없습니다.' : 'Admin access required.');
4070
4205
  return;
4071
4206
  }
4072
- window.location.href = '/admin';
4207
+ sessionStorage.setItem('ltcai_admin_handoff', JSON.stringify({
4208
+ email: currentUserEmail || '',
4209
+ nickname: currentUserNickname || '',
4210
+ is_admin: isAdmin ? 'true' : 'false',
4211
+ token: localStorage.getItem('ltcai_session_token') || '',
4212
+ }));
4213
+ window.location.href = `${API_BASE || ''}/admin`;
4073
4214
  }
4074
4215
 
4075
4216
  function showToast(msg) {
@@ -4411,16 +4552,6 @@
4411
4552
  }
4412
4553
  }
4413
4554
 
4414
- // ── 로컬 파일 브라우저 ────────────────────────────────
4415
- function openLocalBrowser() {
4416
- document.getElementById('local-path-input').value = `${(typeof window !== 'undefined' ? '' : '')}`;
4417
- document.getElementById('local-browser-result').innerHTML = '';
4418
- document.getElementById('local-browser-overlay').style.display = 'flex';
4419
- }
4420
-
4421
- function closeLocalBrowser() {
4422
- document.getElementById('local-browser-overlay').style.display = 'none';
4423
- }
4424
4555
 
4425
4556
  // 권한 요청 Promise 핸들러
4426
4557
  let _permResolve = null;
@@ -4440,13 +4571,35 @@
4440
4571
  if (_permResolve) { _permResolve(allowed); _permResolve = null; }
4441
4572
  }
4442
4573
 
4443
- async function browseLocalPath() {
4444
- const path = document.getElementById('local-path-input').value.trim();
4445
- if (!path) return;
4574
+ let _localCurrentPath = '~';
4575
+
4576
+ async function openLocalBrowser() {
4577
+ document.getElementById('local-browser-overlay').style.display = 'flex';
4578
+ await localNav(_localCurrentPath || '~');
4579
+ }
4580
+
4581
+ function closeLocalBrowser() {
4582
+ document.getElementById('local-browser-overlay').style.display = 'none';
4583
+ }
4584
+
4585
+ async function localNav(path) {
4586
+ _localCurrentPath = path;
4587
+ document.getElementById('local-breadcrumb').textContent = path;
4588
+ await browseLocalPath(path);
4589
+ }
4590
+
4591
+ async function localNavUp() {
4592
+ const parts = _localCurrentPath.replace(/\/$/, '').split('/');
4593
+ if (parts.length <= 1) return;
4594
+ parts.pop();
4595
+ await localNav(parts.join('/') || '/');
4596
+ }
4597
+
4598
+ async function browseLocalPath(path) {
4599
+ path = path ?? _localCurrentPath;
4446
4600
  const resultEl = document.getElementById('local-browser-result');
4447
- resultEl.innerHTML = '<div class="sensitivity-preview">권한 확인 중...</div>';
4601
+ resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
4448
4602
 
4449
- // 1차: permission_required 확인
4450
4603
  const probe = await apiFetch('/local/list', {
4451
4604
  method: 'POST',
4452
4605
  headers: { 'Content-Type': 'application/json' },
@@ -4462,8 +4615,6 @@
4462
4615
  }
4463
4616
  }
4464
4617
 
4465
- // 승인 후 실제 요청
4466
- resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
4467
4618
  try {
4468
4619
  const res = await apiFetch('/local/list', {
4469
4620
  method: 'POST',
@@ -4472,7 +4623,10 @@
4472
4623
  });
4473
4624
  const data = await res.json();
4474
4625
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
4475
- renderLocalListing(data, resultEl);
4626
+ const listing = data.result ?? data;
4627
+ _localCurrentPath = listing.path ?? path;
4628
+ document.getElementById('local-breadcrumb').textContent = _localCurrentPath;
4629
+ renderLocalListing(listing, resultEl);
4476
4630
  } catch(e) {
4477
4631
  resultEl.innerHTML = `<div class="sensitivity-preview">${escapeHtml(e.message)}</div>`;
4478
4632
  }
@@ -4483,29 +4637,152 @@
4483
4637
  container.innerHTML = '<div class="sensitivity-preview">비어 있는 폴더입니다.</div>';
4484
4638
  return;
4485
4639
  }
4486
- container.innerHTML = `
4487
- <div style="color:var(--muted);font-size:11px;margin-bottom:8px;font-weight:700">${escapeHtml(data.path)}</div>
4488
- <div style="display:flex;flex-direction:column;gap:4px">
4489
- ${data.items.map(item => `
4490
- <div style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2);cursor:pointer"
4491
- onclick="${item.type === 'directory' ? `navigateLocalPath('${encodeURIComponent(item.path)}')` : `readLocalFile('${encodeURIComponent(item.path)}')`}">
4492
- <i class="ti ${item.type === 'directory' ? 'ti-folder' : 'ti-file'}" style="color:${item.type === 'directory' ? '#f0a500' : 'var(--muted)'}"></i>
4493
- <span style="flex:1;font-size:13px">${escapeHtml(item.name)}</span>
4494
- ${item.size !== null ? `<span style="color:var(--muted);font-size:11px">${_formatBytes(item.size)}</span>` : ''}
4495
- </div>`).join('')}
4496
- </div>`;
4640
+ container.innerHTML = `<div style="display:flex;flex-direction:column;gap:3px">
4641
+ ${data.items.map(item => {
4642
+ const isDir = item.type === 'directory';
4643
+ const enc = encodeURIComponent(item.path);
4644
+ return `<div style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2);cursor:pointer"
4645
+ onclick="${isDir ? `localNav('${item.path.replace(/'/g,"\\'")}')` : `readLocalFile('${enc}')`}">
4646
+ <i class="ti ${isDir ? 'ti-folder' : 'ti-file'}" style="color:${isDir ? '#f0a500' : 'var(--muted)'}"></i>
4647
+ <span style="flex:1;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(item.name)}</span>
4648
+ ${item.size !== null ? `<span style="color:var(--faint);font-size:11px;flex-shrink:0">${_formatBytes(item.size)}</span>` : ''}
4649
+ </div>`;
4650
+ }).join('')}
4651
+ </div>`;
4497
4652
  }
4498
4653
 
4499
4654
  async function navigateLocalPath(encodedPath) {
4500
- const path = decodeURIComponent(encodedPath);
4501
- document.getElementById('local-path-input').value = path;
4502
- await browseLocalPath();
4655
+ await localNav(decodeURIComponent(encodedPath));
4503
4656
  }
4504
4657
 
4658
+ let _editorCurrentPath = '';
4659
+
4660
+ const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','webp','bmp','svg','ico','tiff','heic']);
4661
+ const VIDEO_EXTS = new Set(['mp4','mov','webm','avi','mkv','m4v']);
4662
+ const DOC_EXTS = new Set(['pdf','docx','xlsx','pptx','doc','xls','ppt','csv','md','txt']);
4663
+ const ARCHIVE_EXTS= new Set(['zip','dmg','pkg','gz','tar','rar','7z','iso']);
4664
+ const BINARY_EXTS = new Set(['exe','bin','dll','so','dylib','db','sqlite','mp3','wav','aac','flac']);
4665
+
4505
4666
  async function readLocalFile(encodedPath) {
4506
4667
  const path = decodeURIComponent(encodedPath);
4507
4668
  const resultEl = document.getElementById('local-browser-result');
4508
- resultEl.innerHTML = '<div class="sensitivity-preview">권한 확인 중...</div>';
4669
+ const ext = path.split('.').pop().toLowerCase();
4670
+
4671
+ // 이미지 파일: 미리보기 + AI 전송
4672
+ if (IMAGE_EXTS.has(ext)) {
4673
+ const safeEnc = encodeURIComponent(path);
4674
+ resultEl.innerHTML = `
4675
+ <div style="text-align:center;padding:12px">
4676
+ <img src="/local/serve?path=${safeEnc}" alt="${escapeHtml(path.split('/').pop())}"
4677
+ style="max-width:100%;max-height:280px;border-radius:8px;border:1px solid var(--border)"
4678
+ onerror="this.parentElement.innerHTML='<div style=color:var(--faint)>이미지 미리보기 불가</div>'">
4679
+ <div style="color:var(--faint);font-size:11px;margin-top:8px">${escapeHtml(path.split('/').pop())}</div>
4680
+ <div style="display:flex;gap:8px;margin-top:12px;justify-content:center">
4681
+ <button class="admin-action" style="font-size:12px;flex:1"
4682
+ onclick="sendImageFileToChat('${path.replace(/\\/g,'\\\\').replace(/'/g,"\\'")}')">
4683
+ <i class='ti ti-send'></i> AI에게 보내기
4684
+ </button>
4685
+ <button class="status-btn" style="font-size:12px"
4686
+ onclick="navigator.clipboard.writeText('${path.replace(/'/g,"\\'")}')">
4687
+ <i class='ti ti-copy'></i> 경로 복사
4688
+ </button>
4689
+ </div>
4690
+ </div>`;
4691
+ return;
4692
+ }
4693
+
4694
+ // 동영상: HTML5 플레이어
4695
+ if (VIDEO_EXTS.has(ext)) {
4696
+ const safeEnc = encodeURIComponent(path);
4697
+ resultEl.innerHTML = `
4698
+ <div style="text-align:center;padding:8px">
4699
+ <video controls style="max-width:100%;max-height:280px;border-radius:8px;border:1px solid var(--border)"
4700
+ src="/local/serve?path=${safeEnc}">지원하지 않는 형식</video>
4701
+ <div style="color:var(--faint);font-size:11px;margin-top:6px">${escapeHtml(path.split('/').pop())}</div>
4702
+ <button class="status-btn" style="font-size:12px;margin-top:8px"
4703
+ onclick="sendArchiveToChat('${path.replace(/'/g,"\\'")}','video')">
4704
+ <i class='ti ti-send'></i> AI에게 경로 보내기
4705
+ </button>
4706
+ </div>`;
4707
+ return;
4708
+ }
4709
+
4710
+ // PDF: 브라우저 내장 뷰어 + AI 전송 (페이지 이미지 + 텍스트)
4711
+ if (ext === 'pdf') {
4712
+ const safeEnc = encodeURIComponent(path);
4713
+ const safePath = path.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
4714
+ resultEl.innerHTML = `
4715
+ <div style="display:flex;flex-direction:column;gap:10px">
4716
+ <embed src="/local/serve?path=${safeEnc}#toolbar=0"
4717
+ type="application/pdf"
4718
+ style="width:100%;height:340px;border-radius:8px;border:1px solid var(--border)">
4719
+ <div style="display:flex;gap:8px">
4720
+ <button class="admin-action" style="flex:1;font-size:12px"
4721
+ onclick="sendPdfToAI('${safePath}')">
4722
+ <i class='ti ti-brain'></i> AI에게 보내기 (이미지+텍스트)
4723
+ </button>
4724
+ <button class="status-btn" style="flex:1;font-size:12px"
4725
+ onclick="openPdfTextEditor('${safePath}')">
4726
+ <i class='ti ti-text-size'></i> 텍스트만 보기
4727
+ </button>
4728
+ </div>
4729
+ <div id="pdf-send-status" style="font-size:12px;color:var(--faint);text-align:center"></div>
4730
+ </div>`;
4731
+ return;
4732
+ }
4733
+
4734
+ // 기타 문서(DOCX/XLSX 등): 텍스트 추출 후 에디터로
4735
+ if (DOC_EXTS.has(ext)) {
4736
+ resultEl.innerHTML = '<div class="sensitivity-preview">문서 읽는 중...</div>';
4737
+ try {
4738
+ const res = await apiFetch('/tools/read_document', {
4739
+ method: 'POST',
4740
+ headers: { 'Content-Type': 'application/json' },
4741
+ body: JSON.stringify({ path })
4742
+ });
4743
+ const data = await res.json();
4744
+ if (!res.ok || data.error) throw new Error(data.error || data.detail || '읽기 실패');
4745
+ const text = (data.result ?? data).content ?? '';
4746
+ _editorCurrentPath = path;
4747
+ document.getElementById('editor-filepath').textContent = path;
4748
+ document.getElementById('file-editor-content').value = text;
4749
+ document.getElementById('editor-status').textContent = '';
4750
+ document.getElementById('local-browser-overlay').style.display = 'none';
4751
+ document.getElementById('file-editor-overlay').style.display = 'flex';
4752
+ } catch(e) {
4753
+ resultEl.innerHTML = `
4754
+ <div class="sensitivity-preview">⚠️ 문서 읽기 실패: ${escapeHtml(e.message)}<br>
4755
+ <button class="admin-action" style="margin-top:10px;font-size:12px"
4756
+ onclick="sendArchiveToChat('${path.replace(/'/g,"\\'")}','document')">
4757
+ <i class='ti ti-send'></i> AI에게 경로 보내기
4758
+ </button>
4759
+ </div>`;
4760
+ }
4761
+ return;
4762
+ }
4763
+
4764
+ // 압축/디스크 이미지: 파일 정보 + AI 경로 전송
4765
+ if (ARCHIVE_EXTS.has(ext)) {
4766
+ resultEl.innerHTML = `
4767
+ <div class="sensitivity-preview" style="text-align:center">
4768
+ <i class="ti ti-archive" style="font-size:36px;color:var(--accent-2);display:block;margin-bottom:8px"></i>
4769
+ <div style="font-size:13px;font-weight:600">${escapeHtml(path.split('/').pop())}</div>
4770
+ <div style="color:var(--faint);font-size:11px;margin-top:4px">${ext.toUpperCase()} 파일 — 직접 열기 불가</div>
4771
+ <button class="admin-action" style="margin-top:12px;font-size:12px"
4772
+ onclick="sendArchiveToChat('${path.replace(/'/g,"\\'")}','archive')">
4773
+ <i class='ti ti-send'></i> AI에게 보내기 (내용 분석 요청)
4774
+ </button>
4775
+ </div>`;
4776
+ return;
4777
+ }
4778
+
4779
+ // 기타 바이너리: 차단
4780
+ if (BINARY_EXTS.has(ext)) {
4781
+ resultEl.innerHTML = `<div class="sensitivity-preview">⚠️ 바이너리 파일은 열 수 없습니다.<br><span style="color:var(--faint);font-size:11px">${escapeHtml(path)}</span></div>`;
4782
+ return;
4783
+ }
4784
+
4785
+ resultEl.innerHTML = '<div class="sensitivity-preview">읽는 중...</div>';
4509
4786
 
4510
4787
  const probe = await apiFetch('/local/read', {
4511
4788
  method: 'POST',
@@ -4529,16 +4806,152 @@
4529
4806
  });
4530
4807
  const data = await res.json();
4531
4808
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
4809
+ const content = (data.result ?? data).content ?? '';
4810
+ // 에디터 열기
4811
+ _editorCurrentPath = path;
4812
+ document.getElementById('editor-filepath').textContent = path;
4813
+ document.getElementById('file-editor-content').value = content;
4814
+ document.getElementById('editor-status').textContent = '';
4815
+ document.getElementById('local-browser-overlay').style.display = 'none';
4816
+ document.getElementById('file-editor-overlay').style.display = 'flex';
4817
+ } catch(e) {
4818
+ resultEl.innerHTML = `<div class="sensitivity-preview">${escapeHtml(e.message)}</div>`;
4819
+ }
4820
+ }
4821
+
4822
+ function closeFileEditor() {
4823
+ document.getElementById('file-editor-overlay').style.display = 'none';
4824
+ }
4825
+
4826
+ async function saveLocalFile() {
4827
+ const path = _editorCurrentPath;
4828
+ const content = document.getElementById('file-editor-content').value;
4829
+ const statusEl = document.getElementById('editor-status');
4830
+ statusEl.style.color = 'var(--accent)';
4831
+ statusEl.textContent = '저장 중...';
4832
+
4833
+ const probe = await apiFetch('/local/write', {
4834
+ method: 'POST',
4835
+ headers: { 'Content-Type': 'application/json' },
4836
+ body: JSON.stringify({ path, content, approved: false })
4837
+ });
4838
+ const probeData = await probe.json();
4839
+ if (probeData.permission_required) {
4840
+ const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
4841
+ if (!allowed) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = '저장 취소됨'; return; }
4842
+ }
4843
+
4844
+ try {
4845
+ const res = await apiFetch('/local/write', {
4846
+ method: 'POST',
4847
+ headers: { 'Content-Type': 'application/json' },
4848
+ body: JSON.stringify({ path, content, approved: true })
4849
+ });
4850
+ const data = await res.json();
4851
+ if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
4852
+ statusEl.style.color = 'var(--accent)';
4853
+ statusEl.textContent = '✓ 저장 완료';
4854
+ setTimeout(() => { statusEl.textContent = ''; }, 2500);
4855
+ } catch(e) {
4856
+ statusEl.style.color = 'var(--danger)';
4857
+ statusEl.textContent = '저장 실패: ' + e.message;
4858
+ }
4859
+ }
4860
+
4861
+ async function sendPdfToAI(path) {
4862
+ const statusEl = document.getElementById('pdf-send-status');
4863
+ if (statusEl) statusEl.textContent = '페이지 렌더링 중...';
4864
+ try {
4865
+ // 1. 페이지 이미지 가져오기
4866
+ const res = await apiFetch('/tools/pdf_pages?path=' + encodeURIComponent(path));
4867
+ const data = await res.json();
4868
+ const pages = data.pages || [];
4869
+
4870
+ // 2. 텍스트도 추출
4871
+ const textRes = await apiFetch('/tools/read_document', {
4872
+ method: 'POST',
4873
+ headers: { 'Content-Type': 'application/json' },
4874
+ body: JSON.stringify({ path })
4875
+ });
4876
+ const textData = await textRes.json();
4877
+ const text = (textData.result ?? textData).content ?? '';
4878
+
4532
4879
  closeLocalBrowser();
4533
- addMessage('ai', `📄 <b>${escapeHtml(path.split('/').pop())}</b> 파일 내용을 불러왔습니다. AI에게 이 파일로 작업을 요청하세요.`);
4534
- // 파일 내용을 입력창에 컨텍스트로 추가
4535
- userInput.value = `다음 파일 내용을 분석해줘:\n\n${data.content.slice(0, 3000)}`;
4880
+
4881
+ if (pages.length > 0) {
4882
+ // 페이지 이미지를 채팅 첨부로 설정
4883
+ const firstPage = pages[0];
4884
+ const blob = await (await fetch(`data:image/png;base64,${firstPage.b64}`)).blob();
4885
+ setImagePreviewFromBlob(blob);
4886
+ }
4887
+
4888
+ // 텍스트 + 페이지 수 안내 메시지
4889
+ const name = path.split('/').pop();
4890
+ const pageInfo = pages.length > 0 ? ` (${data.total}페이지, 첫 페이지 이미지 첨부됨)` : '';
4891
+ userInput.value = `다음 PDF 문서를 분석해줘: ${name}${pageInfo}\n\n[추출된 텍스트]\n${text.slice(0, 3000)}`;
4536
4892
  userInput.focus();
4537
4893
  } catch(e) {
4538
- resultEl.innerHTML = `<div class="sensitivity-preview">${escapeHtml(e.message)}</div>`;
4894
+ if (statusEl) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = '실패: ' + e.message; }
4539
4895
  }
4540
4896
  }
4541
4897
 
4898
+ async function openPdfTextEditor(path) {
4899
+ const resultEl = document.getElementById('local-browser-result');
4900
+ resultEl.innerHTML = '<div class="sensitivity-preview">텍스트 추출 중...</div>';
4901
+ try {
4902
+ const res = await apiFetch('/tools/read_document', {
4903
+ method: 'POST',
4904
+ headers: { 'Content-Type': 'application/json' },
4905
+ body: JSON.stringify({ path })
4906
+ });
4907
+ const data = await res.json();
4908
+ const text = (data.result ?? data).content ?? '';
4909
+ _editorCurrentPath = path;
4910
+ document.getElementById('editor-filepath').textContent = path + ' [텍스트 추출]';
4911
+ document.getElementById('file-editor-content').value = text;
4912
+ document.getElementById('editor-status').textContent = '⚠️ 이미지/표 등 비텍스트 요소는 표시되지 않을 수 있습니다';
4913
+ document.getElementById('editor-status').style.color = 'var(--accent-2)';
4914
+ document.getElementById('local-browser-overlay').style.display = 'none';
4915
+ document.getElementById('file-editor-overlay').style.display = 'flex';
4916
+ } catch(e) {
4917
+ resultEl.innerHTML = `<div class="sensitivity-preview">텍스트 추출 실패: ${escapeHtml(e.message)}</div>`;
4918
+ }
4919
+ }
4920
+
4921
+ function sendArchiveToChat(path, type) {
4922
+ closeLocalBrowser();
4923
+ const name = path.split('/').pop();
4924
+ const prompts = {
4925
+ archive: `${path} 파일의 내용을 분석해줘. (압축 파일이면 내부 목록을 보여주고, 필요하면 압축 해제해줘)`,
4926
+ video: `${path} 영상 파일에 대해 알 수 있는 정보를 알려줘.`,
4927
+ document:`${path} 문서를 읽고 요약해줘.`,
4928
+ };
4929
+ userInput.value = prompts[type] || `${path} 파일을 분석해줘.`;
4930
+ userInput.focus();
4931
+ }
4932
+
4933
+ async function sendImageFileToChat(path) {
4934
+ try {
4935
+ const res = await apiFetch('/local/serve?path=' + encodeURIComponent(path));
4936
+ if (!res.ok) throw new Error('이미지 로드 실패');
4937
+ const blob = await res.blob();
4938
+ closeLocalBrowser();
4939
+ setImagePreviewFromBlob(blob);
4940
+ userInput.value = `이 이미지를 분석해줘: ${path.split('/').pop()}`;
4941
+ userInput.focus();
4942
+ } catch(e) {
4943
+ alert('이미지를 불러올 수 없습니다: ' + e.message);
4944
+ }
4945
+ }
4946
+
4947
+ function sendFileToChat() {
4948
+ const content = document.getElementById('file-editor-content').value;
4949
+ const name = _editorCurrentPath.split('/').pop();
4950
+ closeFileEditor();
4951
+ userInput.value = `다음 파일(${name}) 내용을 분석하거나 수정해줘:\n\n\`\`\`\n${content.slice(0, 4000)}\n\`\`\``;
4952
+ userInput.focus();
4953
+ }
4954
+
4542
4955
  function renderAdminStats(summary) {
4543
4956
  const stats = [
4544
4957
  ['전체 사용자', summary.total_users],
@@ -5011,6 +5424,7 @@
5011
5424
  }
5012
5425
 
5013
5426
  async function openConversation(conversationId) {
5427
+ closeSidebar();
5014
5428
  try {
5015
5429
  let data = null;
5016
5430
  const res = await apiFetch(`/history/conversations/${encodeURIComponent(conversationId)}`);
@@ -5098,6 +5512,13 @@
5098
5512
  }
5099
5513
 
5100
5514
  async function restoreCurrentConversation() {
5515
+ const urlParams = new URLSearchParams(window.location.search);
5516
+ const openId = urlParams.get('open_conversation');
5517
+ if (openId) {
5518
+ history.replaceState(null, '', window.location.pathname);
5519
+ currentConversationId = openId;
5520
+ localStorage.setItem(CONVERSATION_KEY, currentConversationId);
5521
+ }
5101
5522
  const conversations = await loadHistory();
5102
5523
  if (conversations.some(item => item.id === currentConversationId)) {
5103
5524
  await openConversation(currentConversationId);
@@ -5198,7 +5619,8 @@
5198
5619
  async function uploadAttachedDoc(file) {
5199
5620
  const form = new FormData();
5200
5621
  form.append('file', file);
5201
- const res = await apiFetch('/upload/document', { method: 'POST', body: form });
5622
+ const qs = currentConversationId ? `?conversation_id=${encodeURIComponent(currentConversationId)}` : '';
5623
+ const res = await apiFetch(`/upload/document${qs}`, { method: 'POST', body: form });
5202
5624
  if (!res.ok) {
5203
5625
  const err = await res.json().catch(() => ({}));
5204
5626
  throw new Error(err.detail || '파일 업로드 실패');
@@ -5231,17 +5653,53 @@
5231
5653
  'word','excel','powerpoint','ppt','피피티','엑셀','스프레드시트','프레젠테이션',
5232
5654
  '보고서 만들','기획서','제안서','이력서','계약서','파일 생성','문서 생성',
5233
5655
  ];
5656
+ const PROJECT_BUILD_KEYWORDS = [
5657
+ '프로젝트', '앱 만들', '웹앱', '웹 app', 'react', 'vite', 'next.js', 'nextjs',
5658
+ 'vue', 'svelte', 'frontend', 'backend', '서버 만들', 'api 만들', '코드 작성',
5659
+ '개발해', '구현해', 'scaffold', 'boilerplate', 'build', 'compile', 'typecheck',
5660
+ '테스트 돌려', 'npm run build', '빌드해', '배포해', 'deploy',
5661
+ 'installer', 'install file', '.pkg', '.exe', 'pkg', 'exe', 'electron', 'electron-builder',
5662
+ '설치파일', '설치 파일', '실행파일', '패키징'
5663
+ ];
5664
+ const DATA_ANALYSIS_KEYWORDS = [
5665
+ '데이터 분석', '분석해', '통계', '인사이트', '추세', '리포트', '요약표',
5666
+ 'csv', 'xlsx', 'tsv', '매출 데이터', '로그 분석', 'pivot', '회귀', '상관관계',
5667
+ 'data analysis', 'analyze', 'insight', 'trend', 'statistics', 'dataset'
5668
+ ];
5234
5669
 
5235
5670
  function _isFileRequest(text) {
5236
5671
  const t = text.toLowerCase();
5237
5672
  return FILE_KEYWORDS.some(k => t.includes(k));
5238
5673
  }
5239
5674
 
5675
+ function _isProjectOrBuildRequest(text) {
5676
+ const t = text.toLowerCase();
5677
+ return PROJECT_BUILD_KEYWORDS.some(k => t.includes(k));
5678
+ }
5679
+
5680
+ function _isDataAnalysisRequest(text) {
5681
+ const t = text.toLowerCase();
5682
+ return DATA_ANALYSIS_KEYWORDS.some(k => t.includes(k));
5683
+ }
5684
+
5240
5685
  function _isComputerUseRequest(text) {
5241
5686
  const t = text.toLowerCase();
5242
- const desktopWords = ['computer use', 'desktop', 'screen', 'click', 'type', 'scroll', 'chrome', 'safari'];
5243
- const koWords = ['컴퓨터', '데스크탑', '화면', '클릭', '타이핑', '스크롤', '크롬', '사파리', '브라우저', '열어', '켜줘', '실행'];
5244
- return desktopWords.some(k => t.includes(k)) || koWords.some(k => t.includes(k));
5687
+ const controlTargets = [
5688
+ 'computer use', 'desktop', 'screen', 'chrome', 'safari', 'browser',
5689
+ '컴퓨터', '데스크탑', '화면', '크롬', '사파리', '브라우저'
5690
+ ];
5691
+ const controlVerbs = [
5692
+ 'click', 'type', 'scroll', 'open', 'launch', 'press', 'drag',
5693
+ '클릭', '타이핑', '스크롤', '열어', '켜', '실행', '눌러', '드래그'
5694
+ ];
5695
+ const hasTarget = controlTargets.some(k => t.includes(k));
5696
+ const hasVerb = controlVerbs.some(k => t.includes(k));
5697
+ return hasTarget && hasVerb;
5698
+ }
5699
+
5700
+ function _isFileOrProjectRequest(text, hasDocAttachment = false) {
5701
+ if (hasDocAttachment) return true;
5702
+ return _isFileRequest(text) || _isProjectOrBuildRequest(text);
5245
5703
  }
5246
5704
 
5247
5705
  function _isNetworkStatusRequest(text) {
@@ -5413,11 +5871,6 @@
5413
5871
  return;
5414
5872
  }
5415
5873
 
5416
- if (!capturedImage && !capturedDocFile && text && _isComputerUseRequest(text)) {
5417
- await sendToComputerUse(text);
5418
- return;
5419
- }
5420
-
5421
5874
  if (text) recommendMcpForPrompt(text);
5422
5875
 
5423
5876
  if (vscode) {
@@ -5441,12 +5894,39 @@
5441
5894
  const lang = detectLang(text);
5442
5895
  const langCtx = langHint(lang);
5443
5896
 
5444
- // 파일 생성 요청은 /agent로 라우팅
5445
- if (!capturedImage && text && (_isFileRequest(text) || capturedDocFile)) {
5897
+ const wantsFileOrProject = !capturedImage && text && _isFileOrProjectRequest(text, Boolean(capturedDocFile));
5898
+ const wantsDataAnalysis = !capturedImage && text && _isDataAnalysisRequest(text);
5899
+ const wantsComputerUse = !capturedImage && !capturedDocFile && text && _isComputerUseRequest(text);
5900
+
5901
+ // 충돌 시 사용자 선택: 파일 생성(/agent) vs 컴퓨터 제어(/cu/agent)
5902
+ if (wantsFileOrProject && wantsComputerUse) {
5903
+ const useComputer = confirm('요청을 어떻게 처리할까요?\n확인: 내 컴퓨터를 직접 제어(설치/실행)\n취소: 채팅에서 프로젝트 파일 생성(다운로드)');
5904
+ if (useComputer) {
5905
+ await sendToComputerUse(text);
5906
+ } else {
5907
+ await sendToAgent(text + docContext, langCtx);
5908
+ }
5909
+ return;
5910
+ }
5911
+
5912
+ // 파일/프로젝트 생성 요청은 /agent를 우선
5913
+ if (wantsFileOrProject) {
5914
+ await sendToAgent(text + docContext, langCtx);
5915
+ return;
5916
+ }
5917
+
5918
+ // 데이터 분석 요청도 /agent 우선 (파일 읽기/명령 실행/산출물 생성 가능)
5919
+ if (wantsDataAnalysis) {
5446
5920
  await sendToAgent(text + docContext, langCtx);
5447
5921
  return;
5448
5922
  }
5449
5923
 
5924
+ // 명시적 컴퓨터 제어 요청만 /cu/agent로 라우팅
5925
+ if (wantsComputerUse) {
5926
+ await sendToComputerUse(text);
5927
+ return;
5928
+ }
5929
+
5450
5930
  const aiMsgDiv = document.createElement('div');
5451
5931
  aiMsgDiv.className = 'message ai';
5452
5932
  aiMsgDiv.innerHTML = `<div class="sender-label">Lattice AI</div><div class="bubble">생각 중입니다...</div>`;
@@ -5636,6 +6116,19 @@
5636
6116
  }
5637
6117
  })();
5638
6118
 
6119
+ (async function applyRuntimeFeatures() {
6120
+ try {
6121
+ const res = await apiFetch('/runtime_features');
6122
+ if (res.ok) {
6123
+ const f = await res.json();
6124
+ if (!f.graph_enabled) {
6125
+ const btn = document.getElementById('data-graph-btn');
6126
+ if (btn) btn.style.display = 'none';
6127
+ }
6128
+ }
6129
+ } catch (_) {}
6130
+ })();
6131
+
5639
6132
  loadModelStatus();
5640
6133
  loadVpcStatus();
5641
6134
  restoreCurrentConversation();
@@ -6183,12 +6676,14 @@
6183
6676
  apiFetch('/mcp/installed'),
6184
6677
  apiFetch('/mcp/tools'),
6185
6678
  ]);
6186
- const installed = installedRes.ok ? await installedRes.json() : {};
6187
- const allTools = toolsRes.ok ? await toolsRes.json() : [];
6679
+ const installedData = installedRes.ok ? await installedRes.json() : { installed: [] };
6680
+ const toolsData = toolsRes.ok ? await toolsRes.json() : { installed_mcps: [] };
6188
6681
 
6189
- const installedIds = new Set(Object.keys(installed));
6190
- const installedItems = allTools.filter(t => installedIds.has(t.id));
6191
- const availableItems = allTools.filter(t => !installedIds.has(t.id));
6682
+ const allMcps = Array.isArray(installedData?.installed)
6683
+ ? installedData.installed
6684
+ : (Array.isArray(toolsData?.installed_mcps) ? toolsData.installed_mcps : []);
6685
+ const installedItems = allMcps.filter(mcp => mcp.installed);
6686
+ const availableItems = allMcps.filter(mcp => !mcp.installed);
6192
6687
 
6193
6688
  let html = '';
6194
6689
 
@@ -6234,7 +6729,7 @@
6234
6729
  const res = await apiFetch('/mcp/install', {
6235
6730
  method: 'POST',
6236
6731
  headers: { 'Content-Type': 'application/json' },
6237
- body: JSON.stringify({ id }),
6732
+ body: JSON.stringify({ mcp_id: id }),
6238
6733
  });
6239
6734
  if (res.ok) {
6240
6735
  await renderMcpModal();
@@ -6248,6 +6743,15 @@
6248
6743
  }
6249
6744
  }
6250
6745
  </script>
6746
+ <script>
6747
+ // Register Service Worker for PWA install support
6748
+ if ('serviceWorker' in navigator) {
6749
+ window.addEventListener('load', () => {
6750
+ navigator.serviceWorker.register('/sw.js', { scope: '/' })
6751
+ .catch(() => {}); // silent fail — SW is optional enhancement
6752
+ });
6753
+ }
6754
+ </script>
6251
6755
  </body>
6252
6756
 
6253
6757
  </html>