ltcai 0.1.3 → 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
 
@@ -180,6 +191,48 @@
180
191
  flex-shrink: 0;
181
192
  }
182
193
 
194
+ .sidebar-search {
195
+ padding: 8px 10px;
196
+ border-bottom: 1px solid rgba(255,255,255,0.05);
197
+ }
198
+ .sidebar-search input {
199
+ width: 100%;
200
+ padding: 7px 10px 7px 30px;
201
+ background: rgba(255,255,255,0.04);
202
+ border: 1px solid rgba(255,255,255,0.07);
203
+ border-radius: 8px;
204
+ color: var(--text);
205
+ font-size: 12px;
206
+ font-family: inherit;
207
+ outline: none;
208
+ transition: border-color .15s;
209
+ }
210
+ .sidebar-search input:focus { border-color: rgba(34,211,160,0.4); }
211
+ .sidebar-search input::placeholder { color: var(--faint); }
212
+ .sidebar-search-wrap {
213
+ position: relative;
214
+ }
215
+ .sidebar-search-wrap i {
216
+ position: absolute;
217
+ left: 8px; top: 50%;
218
+ transform: translateY(-50%);
219
+ color: var(--faint);
220
+ font-size: 13px;
221
+ pointer-events: none;
222
+ }
223
+ .history-item-del {
224
+ margin-left: auto;
225
+ opacity: 0;
226
+ color: var(--faint);
227
+ font-size: 13px;
228
+ padding: 2px 4px;
229
+ border-radius: 4px;
230
+ transition: all .15s;
231
+ flex-shrink: 0;
232
+ }
233
+ .history-item:hover .history-item-del { opacity: 1; }
234
+ .history-item-del:hover { color: #ff6b6b; background: rgba(255,107,107,0.12); }
235
+
183
236
  .history-container {
184
237
  flex: 1;
185
238
  overflow-y: auto;
@@ -273,6 +326,11 @@
273
326
  box-shadow: 0 0 16px rgba(34,211,160,0.12);
274
327
  }
275
328
 
329
+ .sidebar-primary-actions {
330
+ padding: 8px 10px 10px;
331
+ border-bottom: 1px solid rgba(255,255,255,0.05);
332
+ }
333
+
276
334
  .admin-btn {
277
335
  width: 100%;
278
336
  padding: 9px 12px;
@@ -420,6 +478,62 @@
420
478
  justify-content: center;
421
479
  }
422
480
  .acct-modal-overlay.open { display: flex; }
481
+
482
+ /* MCP 관리 모달 */
483
+ .mcp-modal-overlay {
484
+ display: none;
485
+ position: fixed;
486
+ inset: 0;
487
+ background: rgba(0,0,0,0.6);
488
+ backdrop-filter: blur(4px);
489
+ z-index: 1000;
490
+ align-items: center;
491
+ justify-content: center;
492
+ }
493
+ .mcp-modal-overlay.open { display: flex; }
494
+ .mcp-modal {
495
+ background: var(--surface, #1e293b);
496
+ border: 1px solid rgba(255,255,255,0.08);
497
+ border-radius: 16px;
498
+ width: 100%;
499
+ max-width: 560px;
500
+ max-height: 80vh;
501
+ display: flex;
502
+ flex-direction: column;
503
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5);
504
+ overflow: hidden;
505
+ }
506
+ .mcp-modal-header {
507
+ padding: 18px 20px;
508
+ border-bottom: 1px solid rgba(255,255,255,0.07);
509
+ display: flex; align-items: center; justify-content: space-between;
510
+ }
511
+ .mcp-modal-header h3 { font-size: 15px; font-weight: 700; color: var(--text); }
512
+ .mcp-modal-close { background: none; border: none; color: var(--faint); cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 6px; }
513
+ .mcp-modal-close:hover { color: var(--text); background: rgba(255,255,255,0.07); }
514
+ .mcp-modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
515
+ .mcp-section-label { font-size: 10px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .08em; margin: 12px 0 8px; }
516
+ .mcp-item {
517
+ display: flex; align-items: center; gap: 12px;
518
+ padding: 11px 14px;
519
+ background: rgba(255,255,255,0.03);
520
+ border: 1px solid rgba(255,255,255,0.06);
521
+ border-radius: 10px;
522
+ margin-bottom: 6px;
523
+ }
524
+ .mcp-item-icon { font-size: 20px; flex-shrink: 0; }
525
+ .mcp-item-info { flex: 1; min-width: 0; }
526
+ .mcp-item-name { font-size: 13px; font-weight: 600; color: var(--text); }
527
+ .mcp-item-desc { font-size: 11px; color: var(--faint); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
528
+ .mcp-item-status { font-size: 10.5px; color: var(--accent); font-weight: 600; }
529
+ .mcp-item-status.inactive { color: var(--faint); }
530
+ .mcp-install-btn {
531
+ padding: 6px 12px; border-radius: 7px; font-size: 12px; font-weight: 600;
532
+ border: 1px solid rgba(34,211,160,0.3); background: rgba(34,211,160,0.08);
533
+ color: var(--accent); cursor: pointer; transition: all .15s; flex-shrink: 0;
534
+ }
535
+ .mcp-install-btn:hover { background: rgba(34,211,160,0.15); }
536
+ .mcp-install-btn.installed { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.04); color: var(--faint); }
423
537
  .acct-modal {
424
538
  background: var(--surface, #1e293b);
425
539
  border: 1px solid rgba(255,255,255,0.08);
@@ -761,9 +875,51 @@
761
875
  border: 1px solid var(--border);
762
876
  }
763
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
+
764
919
  /* ── 입력창 ── */
765
920
  .input-area {
766
921
  padding: 14px 20px 20px;
922
+ padding-bottom: max(20px, env(safe-area-inset-bottom));
767
923
  background: linear-gradient(0deg, rgba(24,35,50,0.96) 0%, transparent 100%);
768
924
  }
769
925
 
@@ -2068,97 +2224,107 @@
2068
2224
  background: var(--muted);
2069
2225
  }
2070
2226
 
2227
+ /* ── 태블릿 (≤900px) ── */
2071
2228
  @media (max-width: 900px) {
2072
- body {
2073
- overflow: hidden;
2074
- }
2229
+ .status-pill.hide-mobile { display: none; }
2075
2230
 
2076
- .app-layout {
2077
- flex-direction: column;
2078
- }
2079
-
2080
- .sidebar {
2081
- width: 100%;
2082
- min-width: 0;
2083
- max-height: 132px;
2084
- border-right: none;
2085
- border-bottom: 1px solid var(--border);
2086
- }
2087
-
2088
- .sidebar-header {
2089
- padding: 12px 14px;
2090
- }
2091
-
2092
- .user-strip,
2093
- .history-container {
2094
- display: none;
2231
+ .ops-strip {
2232
+ width: calc(100% - 28px);
2233
+ grid-template-columns: 1fr;
2234
+ margin-top: 12px;
2095
2235
  }
2096
2236
 
2097
- .sidebar-footer {
2098
- padding: 0 14px 12px;
2099
- border-top: none;
2100
- }
2237
+ .bubble { max-width: 92%; }
2101
2238
 
2102
- .chat-header {
2103
- align-items: flex-start;
2104
- padding: 10px 14px;
2105
- }
2239
+ .empty-grid { grid-template-columns: 1fr; }
2240
+ .empty-state { margin-top: 4vh; }
2106
2241
 
2107
- .header-pills {
2108
- gap: 6px;
2109
- }
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; }
2110
2246
 
2111
- .status-pill.hide-mobile {
2112
- display: none;
2247
+ .admin-table {
2248
+ display: block;
2249
+ overflow-x: auto;
2250
+ white-space: nowrap;
2113
2251
  }
2252
+ }
2114
2253
 
2254
+ /* ── 모바일 드로어 (≤768px) ── */
2255
+ @media (max-width: 768px) {
2256
+ /* ops-strip: 가로 스크롤 한 줄로 압축 */
2115
2257
  .ops-strip {
2116
- width: calc(100% - 28px);
2117
- grid-template-columns: 1fr;
2118
- margin-top: 12px;
2119
- }
2120
-
2121
- .messages-viewport {
2122
- padding: 20px 14px 14px;
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;
2123
2266
  }
2124
-
2125
- .bubble {
2126
- max-width: 92%;
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;
2127
2273
  }
2128
-
2129
- .input-area {
2130
- padding: 12px 14px 14px;
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;
2131
2300
  }
2132
-
2133
- .empty-grid {
2134
- grid-template-columns: 1fr;
2301
+ body.sidebar-open .sidebar {
2302
+ transform: translateX(0);
2135
2303
  }
2136
2304
 
2137
- .empty-state {
2138
- margin-top: 4vh;
2139
- }
2305
+ .sidebar-header { padding: 14px 14px; }
2306
+ .sidebar-footer { padding: 10px 14px 14px; border-top: 1px solid var(--border); }
2140
2307
 
2141
- .admin-stats {
2142
- grid-template-columns: repeat(2, minmax(0, 1fr));
2143
- }
2308
+ /* 메인 채팅은 항상 전체 너비 */
2309
+ .main-chat { width: 100%; flex: 1; }
2144
2310
 
2145
- .sensitivity-grid {
2146
- grid-template-columns: 1fr;
2147
- }
2311
+ .chat-header { padding: 10px 14px; }
2312
+ .header-pills { gap: 6px; }
2148
2313
 
2149
- .admin-form-grid {
2150
- grid-template-columns: 1fr;
2151
- }
2314
+ .messages-viewport { padding: 16px 12px 12px; }
2315
+ .input-area { padding: 10px 12px max(14px, env(safe-area-inset-bottom)); }
2152
2316
 
2153
- .mcp-list {
2154
- grid-template-columns: 1fr;
2155
- }
2317
+ .bubble { max-width: 94%; }
2318
+ }
2156
2319
 
2157
- .admin-table {
2158
- display: block;
2159
- overflow-x: auto;
2160
- white-space: nowrap;
2161
- }
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; }
2162
2328
  }
2163
2329
  </style>
2164
2330
 
@@ -2603,6 +2769,14 @@
2603
2769
  box-shadow: inset -1px 0 0 rgba(255,255,255,0.025);
2604
2770
  }
2605
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
+
2606
2780
  .app-layout .sidebar-header,
2607
2781
  .app-layout .user-strip,
2608
2782
  .app-layout .sidebar-footer {
@@ -2811,6 +2985,7 @@
2811
2985
 
2812
2986
 
2813
2987
  <div class="app-layout">
2988
+ <div class="sidebar-overlay" onclick="closeSidebar()"></div>
2814
2989
  <!-- Sidebar -->
2815
2990
  <aside class="sidebar">
2816
2991
  <div class="sidebar-header">
@@ -2819,6 +2994,7 @@
2819
2994
  <div class="brand-title">Lattice AI</div>
2820
2995
  <div class="brand-subtitle">Local MLX Workspace</div>
2821
2996
  </div>
2997
+ <button class="sidebar-close" onclick="closeSidebar()" title="닫기"><i class="ti ti-x"></i></button>
2822
2998
  </div>
2823
2999
  <div class="user-strip">
2824
3000
  <div class="user-avatar" id="user-avatar-initial">G</div>
@@ -2827,14 +3003,24 @@
2827
3003
  <div style="font-size:10.5px;color:var(--faint)">Local Workspace</div>
2828
3004
  </div>
2829
3005
  </div>
3006
+ <div class="sidebar-search">
3007
+ <div class="sidebar-search-wrap">
3008
+ <i class="ti ti-search"></i>
3009
+ <input type="text" id="history-search-input" placeholder="대화 검색..." oninput="onHistorySearch(this.value)">
3010
+ </div>
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>
2830
3015
  <div class="history-container" id="history-container">
2831
3016
  <!-- History items -->
2832
3017
  </div>
2833
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>
2834
3020
  <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> <span data-i18n="admin_dashboard">관리자 대시보드</span></button>
2835
3021
  <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> <span data-i18n="my_status">내 상태 보기</span></button>
2836
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>
2837
- <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
3023
+ <button class="setup-wizard-sidebar-btn" onclick="openMcpModal()"><i class="ti ti-plug-connected"></i> MCP 관리</button>
2838
3024
  </div>
2839
3025
  </aside>
2840
3026
 
@@ -2842,6 +3028,7 @@
2842
3028
  <main class="main-chat">
2843
3029
  <header class="chat-header">
2844
3030
  <div class="header-left">
3031
+ <button class="sidebar-toggle" onclick="toggleSidebar()" title="메뉴"><i class="ti ti-menu-2"></i></button>
2845
3032
  <div class="model-badge">
2846
3033
  <div class="status-dot"></div>
2847
3034
  <span>Gemma-4 Multimodal Agent</span>
@@ -2909,6 +3096,19 @@
2909
3096
  </div>
2910
3097
  </div>
2911
3098
 
3099
+ <!-- MCP 관리 모달 -->
3100
+ <div class="mcp-modal-overlay" id="mcp-modal-overlay" onclick="if(event.target===this)closeMcpModal()">
3101
+ <div class="mcp-modal">
3102
+ <div class="mcp-modal-header">
3103
+ <h3><i class="ti ti-plug-connected"></i> MCP 서버 관리</h3>
3104
+ <button class="mcp-modal-close" onclick="closeMcpModal()"><i class="ti ti-x"></i></button>
3105
+ </div>
3106
+ <div class="mcp-modal-body" id="mcp-modal-body">
3107
+ <div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">로딩 중...</div>
3108
+ </div>
3109
+ </div>
3110
+ </div>
3111
+
2912
3112
  <section class="ops-strip" aria-label="workspace status">
2913
3113
  <div class="ops-card primary interactive" onclick="openModelPanel()">
2914
3114
  <div>
@@ -3030,22 +3230,58 @@
3030
3230
  </section>
3031
3231
  </div>
3032
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
+
3033
3265
  <!-- ── 로컬 파일 브라우저 ── -->
3034
3266
  <div id="local-browser-overlay" class="admin-overlay" style="display:none">
3035
- <section class="admin-panel" style="max-width:540px">
3267
+ <section class="admin-panel" style="max-width:560px">
3036
3268
  <div class="admin-header">
3037
- <div>
3038
- <h2><i class="ti ti-folder-open" style="color:var(--accent)"></i> 로컬 파일 접근</h2>
3039
- <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>
3040
3272
  </div>
3041
3273
  <button class="admin-close" onclick="closeLocalBrowser()"><i class="ti ti-x"></i></button>
3042
3274
  </div>
3043
- <div class="admin-body">
3044
- <div style="display:flex;gap:8px;margin-bottom:14px">
3045
- <input id="local-path-input" class="admin-input" style="flex:1" placeholder="/Users/parktaesoo/Downloads" value="">
3046
- <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>
3047
3283
  </div>
3048
- <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>
3049
3285
  </div>
3050
3286
  </section>
3051
3287
  </div>
@@ -3302,7 +3538,10 @@
3302
3538
  }
3303
3539
 
3304
3540
  function apiFetch(path, options = {}) {
3305
- 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 });
3306
3545
  }
3307
3546
 
3308
3547
  function createConversationId() {
@@ -3332,9 +3571,21 @@
3332
3571
  localStorage.removeItem('ltcai_user_email');
3333
3572
  localStorage.removeItem('ltcai_user_nickname');
3334
3573
  localStorage.removeItem('ltcai_is_admin');
3574
+ localStorage.removeItem('ltcai_session_token');
3335
3575
  window.location.href = '/account';
3336
3576
  }
3337
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
+
3338
3589
  const I18N = {
3339
3590
  ko: {
3340
3591
  // 인증
@@ -3572,9 +3823,11 @@
3572
3823
  }
3573
3824
 
3574
3825
  function adminHeaders() {
3826
+ const token = localStorage.getItem('ltcai_session_token') || '';
3575
3827
  return {
3576
3828
  'Content-Type': 'application/json',
3577
- 'X-Admin-Email': currentUserEmail
3829
+ 'X-Admin-Email': currentUserEmail,
3830
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
3578
3831
  };
3579
3832
  }
3580
3833
 
@@ -3951,7 +4204,13 @@
3951
4204
  showToast(currentLang === 'ko' ? '관리자 권한이 없습니다.' : 'Admin access required.');
3952
4205
  return;
3953
4206
  }
3954
- 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`;
3955
4214
  }
3956
4215
 
3957
4216
  function showToast(msg) {
@@ -4293,16 +4552,6 @@
4293
4552
  }
4294
4553
  }
4295
4554
 
4296
- // ── 로컬 파일 브라우저 ────────────────────────────────
4297
- function openLocalBrowser() {
4298
- document.getElementById('local-path-input').value = `${(typeof window !== 'undefined' ? '' : '')}`;
4299
- document.getElementById('local-browser-result').innerHTML = '';
4300
- document.getElementById('local-browser-overlay').style.display = 'flex';
4301
- }
4302
-
4303
- function closeLocalBrowser() {
4304
- document.getElementById('local-browser-overlay').style.display = 'none';
4305
- }
4306
4555
 
4307
4556
  // 권한 요청 Promise 핸들러
4308
4557
  let _permResolve = null;
@@ -4322,13 +4571,35 @@
4322
4571
  if (_permResolve) { _permResolve(allowed); _permResolve = null; }
4323
4572
  }
4324
4573
 
4325
- async function browseLocalPath() {
4326
- const path = document.getElementById('local-path-input').value.trim();
4327
- 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;
4328
4600
  const resultEl = document.getElementById('local-browser-result');
4329
- resultEl.innerHTML = '<div class="sensitivity-preview">권한 확인 중...</div>';
4601
+ resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
4330
4602
 
4331
- // 1차: permission_required 확인
4332
4603
  const probe = await apiFetch('/local/list', {
4333
4604
  method: 'POST',
4334
4605
  headers: { 'Content-Type': 'application/json' },
@@ -4344,8 +4615,6 @@
4344
4615
  }
4345
4616
  }
4346
4617
 
4347
- // 승인 후 실제 요청
4348
- resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
4349
4618
  try {
4350
4619
  const res = await apiFetch('/local/list', {
4351
4620
  method: 'POST',
@@ -4354,7 +4623,10 @@
4354
4623
  });
4355
4624
  const data = await res.json();
4356
4625
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
4357
- 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);
4358
4630
  } catch(e) {
4359
4631
  resultEl.innerHTML = `<div class="sensitivity-preview">${escapeHtml(e.message)}</div>`;
4360
4632
  }
@@ -4365,29 +4637,152 @@
4365
4637
  container.innerHTML = '<div class="sensitivity-preview">비어 있는 폴더입니다.</div>';
4366
4638
  return;
4367
4639
  }
4368
- container.innerHTML = `
4369
- <div style="color:var(--muted);font-size:11px;margin-bottom:8px;font-weight:700">${escapeHtml(data.path)}</div>
4370
- <div style="display:flex;flex-direction:column;gap:4px">
4371
- ${data.items.map(item => `
4372
- <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"
4373
- onclick="${item.type === 'directory' ? `navigateLocalPath('${encodeURIComponent(item.path)}')` : `readLocalFile('${encodeURIComponent(item.path)}')`}">
4374
- <i class="ti ${item.type === 'directory' ? 'ti-folder' : 'ti-file'}" style="color:${item.type === 'directory' ? '#f0a500' : 'var(--muted)'}"></i>
4375
- <span style="flex:1;font-size:13px">${escapeHtml(item.name)}</span>
4376
- ${item.size !== null ? `<span style="color:var(--muted);font-size:11px">${_formatBytes(item.size)}</span>` : ''}
4377
- </div>`).join('')}
4378
- </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>`;
4379
4652
  }
4380
4653
 
4381
4654
  async function navigateLocalPath(encodedPath) {
4382
- const path = decodeURIComponent(encodedPath);
4383
- document.getElementById('local-path-input').value = path;
4384
- await browseLocalPath();
4655
+ await localNav(decodeURIComponent(encodedPath));
4385
4656
  }
4386
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
+
4387
4666
  async function readLocalFile(encodedPath) {
4388
4667
  const path = decodeURIComponent(encodedPath);
4389
4668
  const resultEl = document.getElementById('local-browser-result');
4390
- 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>';
4391
4786
 
4392
4787
  const probe = await apiFetch('/local/read', {
4393
4788
  method: 'POST',
@@ -4411,16 +4806,152 @@
4411
4806
  });
4412
4807
  const data = await res.json();
4413
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
+
4414
4879
  closeLocalBrowser();
4415
- addMessage('ai', `📄 <b>${escapeHtml(path.split('/').pop())}</b> 파일 내용을 불러왔습니다. AI에게 이 파일로 작업을 요청하세요.`);
4416
- // 파일 내용을 입력창에 컨텍스트로 추가
4417
- 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)}`;
4418
4892
  userInput.focus();
4419
4893
  } catch(e) {
4420
- resultEl.innerHTML = `<div class="sensitivity-preview">${escapeHtml(e.message)}</div>`;
4894
+ if (statusEl) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = '실패: ' + e.message; }
4895
+ }
4896
+ }
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>`;
4421
4918
  }
4422
4919
  }
4423
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
+
4424
4955
  function renderAdminStats(summary) {
4425
4956
  const stats = [
4426
4957
  ['전체 사용자', summary.total_users],
@@ -4893,6 +5424,7 @@
4893
5424
  }
4894
5425
 
4895
5426
  async function openConversation(conversationId) {
5427
+ closeSidebar();
4896
5428
  try {
4897
5429
  let data = null;
4898
5430
  const res = await apiFetch(`/history/conversations/${encodeURIComponent(conversationId)}`);
@@ -4926,28 +5458,67 @@
4926
5458
  }
4927
5459
  }
4928
5460
 
5461
+ function renderHistoryItems(conversations) {
5462
+ if (!conversations.length) {
5463
+ historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
5464
+ return;
5465
+ }
5466
+ historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
5467
+ <div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
5468
+ <i class="ti ti-message-2"></i>
5469
+ <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">${escapeHtml(item.title || '새 대화')}</span>
5470
+ <span class="history-item-del" onclick="event.stopPropagation();deleteConversation('${escapeHtml(item.id)}')"><i class="ti ti-trash"></i></span>
5471
+ </div>
5472
+ `).join('');
5473
+ historyContainer.querySelectorAll('.history-item').forEach(item => {
5474
+ item.onclick = () => openConversation(item.dataset.conversationId);
5475
+ });
5476
+ }
5477
+
4929
5478
  async function loadHistory() {
4930
5479
  try {
4931
5480
  const conversations = await fetchConversations();
4932
- if (!conversations.length) {
4933
- historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
4934
- return [];
4935
- }
4936
- historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
4937
- <div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
4938
- <i class="ti ti-message-2"></i>
4939
- <span>${escapeHtml(item.title || '새 대화')}</span>
4940
- </div>
4941
- `).join('');
4942
- historyContainer.querySelectorAll('.history-item').forEach(item => {
4943
- item.onclick = () => openConversation(item.dataset.conversationId);
4944
- });
5481
+ renderHistoryItems(conversations);
4945
5482
  return conversations;
4946
5483
  } catch (e) { }
4947
5484
  return [];
4948
5485
  }
4949
5486
 
5487
+ let _searchDebounce = null;
5488
+ async function onHistorySearch(q) {
5489
+ clearTimeout(_searchDebounce);
5490
+ if (!q.trim()) { loadHistory(); return; }
5491
+ _searchDebounce = setTimeout(async () => {
5492
+ try {
5493
+ const res = await apiFetch(`/history/search?q=${encodeURIComponent(q)}`);
5494
+ if (!res.ok) return;
5495
+ const data = await res.json();
5496
+ const results = (data.results || []).map(r => ({
5497
+ id: r.conversation_id,
5498
+ title: r.title || '새 대화',
5499
+ }));
5500
+ renderHistoryItems(results);
5501
+ } catch {}
5502
+ }, 300);
5503
+ }
5504
+
5505
+ async function deleteConversation(conversationId) {
5506
+ if (!confirm('이 대화를 삭제할까요?')) return;
5507
+ try {
5508
+ await apiFetch(`/history/conversations/${encodeURIComponent(conversationId)}`, { method: 'DELETE' });
5509
+ if (currentConversationId === conversationId) startNewConversation();
5510
+ loadHistory();
5511
+ } catch {}
5512
+ }
5513
+
4950
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
+ }
4951
5522
  const conversations = await loadHistory();
4952
5523
  if (conversations.some(item => item.id === currentConversationId)) {
4953
5524
  await openConversation(currentConversationId);
@@ -5048,7 +5619,8 @@
5048
5619
  async function uploadAttachedDoc(file) {
5049
5620
  const form = new FormData();
5050
5621
  form.append('file', file);
5051
- 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 });
5052
5624
  if (!res.ok) {
5053
5625
  const err = await res.json().catch(() => ({}));
5054
5626
  throw new Error(err.detail || '파일 업로드 실패');
@@ -5081,17 +5653,53 @@
5081
5653
  'word','excel','powerpoint','ppt','피피티','엑셀','스프레드시트','프레젠테이션',
5082
5654
  '보고서 만들','기획서','제안서','이력서','계약서','파일 생성','문서 생성',
5083
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
+ ];
5084
5669
 
5085
5670
  function _isFileRequest(text) {
5086
5671
  const t = text.toLowerCase();
5087
5672
  return FILE_KEYWORDS.some(k => t.includes(k));
5088
5673
  }
5089
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
+
5090
5685
  function _isComputerUseRequest(text) {
5091
5686
  const t = text.toLowerCase();
5092
- const desktopWords = ['computer use', 'desktop', 'screen', 'click', 'type', 'scroll', 'chrome', 'safari'];
5093
- const koWords = ['컴퓨터', '데스크탑', '화면', '클릭', '타이핑', '스크롤', '크롬', '사파리', '브라우저', '열어', '켜줘', '실행'];
5094
- 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);
5095
5703
  }
5096
5704
 
5097
5705
  function _isNetworkStatusRequest(text) {
@@ -5263,11 +5871,6 @@
5263
5871
  return;
5264
5872
  }
5265
5873
 
5266
- if (!capturedImage && !capturedDocFile && text && _isComputerUseRequest(text)) {
5267
- await sendToComputerUse(text);
5268
- return;
5269
- }
5270
-
5271
5874
  if (text) recommendMcpForPrompt(text);
5272
5875
 
5273
5876
  if (vscode) {
@@ -5291,12 +5894,39 @@
5291
5894
  const lang = detectLang(text);
5292
5895
  const langCtx = langHint(lang);
5293
5896
 
5294
- // 파일 생성 요청은 /agent로 라우팅
5295
- 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) {
5296
5914
  await sendToAgent(text + docContext, langCtx);
5297
5915
  return;
5298
5916
  }
5299
5917
 
5918
+ // 데이터 분석 요청도 /agent 우선 (파일 읽기/명령 실행/산출물 생성 가능)
5919
+ if (wantsDataAnalysis) {
5920
+ await sendToAgent(text + docContext, langCtx);
5921
+ return;
5922
+ }
5923
+
5924
+ // 명시적 컴퓨터 제어 요청만 /cu/agent로 라우팅
5925
+ if (wantsComputerUse) {
5926
+ await sendToComputerUse(text);
5927
+ return;
5928
+ }
5929
+
5300
5930
  const aiMsgDiv = document.createElement('div');
5301
5931
  aiMsgDiv.className = 'message ai';
5302
5932
  aiMsgDiv.innerHTML = `<div class="sender-label">Lattice AI</div><div class="bubble">생각 중입니다...</div>`;
@@ -5486,6 +6116,19 @@
5486
6116
  }
5487
6117
  })();
5488
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
+
5489
6132
  loadModelStatus();
5490
6133
  loadVpcStatus();
5491
6134
  restoreCurrentConversation();
@@ -6013,6 +6656,102 @@
6013
6656
  _footBtns(`<button class="wbtn wbtn-primary" onclick="closeSetupWizard();loadModelStatus()">완료 ✓</button>`);
6014
6657
  }
6015
6658
  </script>
6659
+
6660
+ <script>
6661
+ // ── MCP 관리 모달 ────────────────────────────────────────────────────────
6662
+ async function openMcpModal() {
6663
+ document.getElementById('mcp-modal-overlay').classList.add('open');
6664
+ await renderMcpModal();
6665
+ }
6666
+
6667
+ function closeMcpModal() {
6668
+ document.getElementById('mcp-modal-overlay').classList.remove('open');
6669
+ }
6670
+
6671
+ async function renderMcpModal() {
6672
+ const body = document.getElementById('mcp-modal-body');
6673
+ body.innerHTML = '<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">로딩 중...</div>';
6674
+ try {
6675
+ const [installedRes, toolsRes] = await Promise.all([
6676
+ apiFetch('/mcp/installed'),
6677
+ apiFetch('/mcp/tools'),
6678
+ ]);
6679
+ const installedData = installedRes.ok ? await installedRes.json() : { installed: [] };
6680
+ const toolsData = toolsRes.ok ? await toolsRes.json() : { installed_mcps: [] };
6681
+
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);
6687
+
6688
+ let html = '';
6689
+
6690
+ if (installedItems.length) {
6691
+ html += '<div class="mcp-section-label">설치됨</div>';
6692
+ html += installedItems.map(mcp => `
6693
+ <div class="mcp-item">
6694
+ <div class="mcp-item-icon">${mcp.icon || '🔌'}</div>
6695
+ <div class="mcp-item-info">
6696
+ <div class="mcp-item-name">${escapeHtml(mcp.name || mcp.id)}</div>
6697
+ <div class="mcp-item-desc">${escapeHtml(mcp.description || '')}</div>
6698
+ </div>
6699
+ <span class="mcp-item-status">활성</span>
6700
+ </div>
6701
+ `).join('');
6702
+ }
6703
+
6704
+ if (availableItems.length) {
6705
+ html += '<div class="mcp-section-label">설치 가능</div>';
6706
+ html += availableItems.map(mcp => `
6707
+ <div class="mcp-item" id="mcp-item-${escapeHtml(mcp.id)}">
6708
+ <div class="mcp-item-icon">${mcp.icon || '🔌'}</div>
6709
+ <div class="mcp-item-info">
6710
+ <div class="mcp-item-name">${escapeHtml(mcp.name || mcp.id)}</div>
6711
+ <div class="mcp-item-desc">${escapeHtml(mcp.description || '')}</div>
6712
+ </div>
6713
+ <button class="mcp-install-btn" onclick="installMcp('${escapeHtml(mcp.id)}')">설치</button>
6714
+ </div>
6715
+ `).join('');
6716
+ }
6717
+
6718
+ if (!html) html = '<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">사용 가능한 MCP 서버가 없습니다.</div>';
6719
+ body.innerHTML = html;
6720
+ } catch (e) {
6721
+ body.innerHTML = `<div style="color:#ff6b6b;font-size:13px;text-align:center;padding:24px">로드 실패: ${escapeHtml(e.message)}</div>`;
6722
+ }
6723
+ }
6724
+
6725
+ async function installMcp(id) {
6726
+ const btn = document.querySelector(`#mcp-item-${CSS.escape(id)} .mcp-install-btn`);
6727
+ if (btn) { btn.disabled = true; btn.textContent = '설치 중...'; }
6728
+ try {
6729
+ const res = await apiFetch('/mcp/install', {
6730
+ method: 'POST',
6731
+ headers: { 'Content-Type': 'application/json' },
6732
+ body: JSON.stringify({ mcp_id: id }),
6733
+ });
6734
+ if (res.ok) {
6735
+ await renderMcpModal();
6736
+ } else {
6737
+ const d = await res.json().catch(() => ({}));
6738
+ if (btn) { btn.disabled = false; btn.textContent = '설치'; }
6739
+ alert(d.detail || '설치 실패');
6740
+ }
6741
+ } catch {
6742
+ if (btn) { btn.disabled = false; btn.textContent = '설치'; }
6743
+ }
6744
+ }
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>
6016
6755
  </body>
6017
6756
 
6018
6757
  </html>