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/README.md +121 -0
- package/docs/OPERATIONS.md +149 -0
- package/knowledge_graph.py +802 -0
- package/ltcai_cli.py +45 -1
- package/package.json +15 -3
- package/requirements.txt +2 -0
- package/server.py +818 -39
- package/skills/SKILL_TEMPLATE.md +57 -0
- package/skills/code_review/SKILL.md +76 -0
- package/skills/data_analysis/SKILL.md +79 -0
- package/skills/file_edit/SKILL.md +68 -0
- package/skills/web_search/SKILL.md +74 -0
- package/static/account.html +74 -2
- package/static/admin.html +225 -6
- package/static/chat.html +886 -147
- package/static/graph.html +612 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/manifest.json +35 -0
- package/static/sw.js +51 -0
- package/telegram_bot.py +631 -217
- package/tests/__init__.py +0 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +94 -0
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +127 -0
- package/tools.py +169 -13
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:
|
|
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
|
-
|
|
2073
|
-
overflow: hidden;
|
|
2074
|
-
}
|
|
2229
|
+
.status-pill.hide-mobile { display: none; }
|
|
2075
2230
|
|
|
2076
|
-
.
|
|
2077
|
-
|
|
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
|
-
.
|
|
2098
|
-
padding: 0 14px 12px;
|
|
2099
|
-
border-top: none;
|
|
2100
|
-
}
|
|
2237
|
+
.bubble { max-width: 92%; }
|
|
2101
2238
|
|
|
2102
|
-
.
|
|
2103
|
-
|
|
2104
|
-
padding: 10px 14px;
|
|
2105
|
-
}
|
|
2239
|
+
.empty-grid { grid-template-columns: 1fr; }
|
|
2240
|
+
.empty-state { margin-top: 4vh; }
|
|
2106
2241
|
|
|
2107
|
-
.
|
|
2108
|
-
|
|
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
|
-
.
|
|
2112
|
-
display:
|
|
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
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
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
|
-
.
|
|
2126
|
-
|
|
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
|
-
.
|
|
2130
|
-
|
|
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
|
-
|
|
2134
|
-
grid-template-columns: 1fr;
|
|
2301
|
+
body.sidebar-open .sidebar {
|
|
2302
|
+
transform: translateX(0);
|
|
2135
2303
|
}
|
|
2136
2304
|
|
|
2137
|
-
.
|
|
2138
|
-
|
|
2139
|
-
}
|
|
2305
|
+
.sidebar-header { padding: 14px 14px; }
|
|
2306
|
+
.sidebar-footer { padding: 10px 14px 14px; border-top: 1px solid var(--border); }
|
|
2140
2307
|
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
}
|
|
2308
|
+
/* 메인 채팅은 항상 전체 너비 */
|
|
2309
|
+
.main-chat { width: 100%; flex: 1; }
|
|
2144
2310
|
|
|
2145
|
-
.
|
|
2146
|
-
|
|
2147
|
-
}
|
|
2311
|
+
.chat-header { padding: 10px 14px; }
|
|
2312
|
+
.header-pills { gap: 6px; }
|
|
2148
2313
|
|
|
2149
|
-
.
|
|
2150
|
-
|
|
2151
|
-
}
|
|
2314
|
+
.messages-viewport { padding: 16px 12px 12px; }
|
|
2315
|
+
.input-area { padding: 10px 12px max(14px, env(safe-area-inset-bottom)); }
|
|
2152
2316
|
|
|
2153
|
-
.
|
|
2154
|
-
|
|
2155
|
-
}
|
|
2317
|
+
.bubble { max-width: 94%; }
|
|
2318
|
+
}
|
|
2156
2319
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
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
|
|
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:
|
|
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> 로컬
|
|
3039
|
-
<
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
<button class="
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
onclick="${
|
|
4374
|
-
<i class="ti ${
|
|
4375
|
-
<span style="flex:1;font-size:13px">${escapeHtml(item.name)}</span>
|
|
4376
|
-
${item.size !== null ? `<span style="color:var(--
|
|
4377
|
-
</div
|
|
4378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5093
|
-
|
|
5094
|
-
|
|
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
|
-
|
|
5295
|
-
|
|
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>
|