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