ltcai 0.1.2 → 0.1.4

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.
@@ -180,6 +180,48 @@
180
180
  flex-shrink: 0;
181
181
  }
182
182
 
183
+ .sidebar-search {
184
+ padding: 8px 10px;
185
+ border-bottom: 1px solid rgba(255,255,255,0.05);
186
+ }
187
+ .sidebar-search input {
188
+ width: 100%;
189
+ padding: 7px 10px 7px 30px;
190
+ background: rgba(255,255,255,0.04);
191
+ border: 1px solid rgba(255,255,255,0.07);
192
+ border-radius: 8px;
193
+ color: var(--text);
194
+ font-size: 12px;
195
+ font-family: inherit;
196
+ outline: none;
197
+ transition: border-color .15s;
198
+ }
199
+ .sidebar-search input:focus { border-color: rgba(34,211,160,0.4); }
200
+ .sidebar-search input::placeholder { color: var(--faint); }
201
+ .sidebar-search-wrap {
202
+ position: relative;
203
+ }
204
+ .sidebar-search-wrap i {
205
+ position: absolute;
206
+ left: 8px; top: 50%;
207
+ transform: translateY(-50%);
208
+ color: var(--faint);
209
+ font-size: 13px;
210
+ pointer-events: none;
211
+ }
212
+ .history-item-del {
213
+ margin-left: auto;
214
+ opacity: 0;
215
+ color: var(--faint);
216
+ font-size: 13px;
217
+ padding: 2px 4px;
218
+ border-radius: 4px;
219
+ transition: all .15s;
220
+ flex-shrink: 0;
221
+ }
222
+ .history-item:hover .history-item-del { opacity: 1; }
223
+ .history-item-del:hover { color: #ff6b6b; background: rgba(255,107,107,0.12); }
224
+
183
225
  .history-container {
184
226
  flex: 1;
185
227
  overflow-y: auto;
@@ -335,6 +377,8 @@
335
377
  background: rgba(29,42,60,0.82);
336
378
  backdrop-filter: blur(20px);
337
379
  -webkit-backdrop-filter: blur(20px);
380
+ position: relative;
381
+ z-index: 50;
338
382
  }
339
383
 
340
384
  .header-left, .header-pills {
@@ -418,6 +462,62 @@
418
462
  justify-content: center;
419
463
  }
420
464
  .acct-modal-overlay.open { display: flex; }
465
+
466
+ /* MCP 관리 모달 */
467
+ .mcp-modal-overlay {
468
+ display: none;
469
+ position: fixed;
470
+ inset: 0;
471
+ background: rgba(0,0,0,0.6);
472
+ backdrop-filter: blur(4px);
473
+ z-index: 1000;
474
+ align-items: center;
475
+ justify-content: center;
476
+ }
477
+ .mcp-modal-overlay.open { display: flex; }
478
+ .mcp-modal {
479
+ background: var(--surface, #1e293b);
480
+ border: 1px solid rgba(255,255,255,0.08);
481
+ border-radius: 16px;
482
+ width: 100%;
483
+ max-width: 560px;
484
+ max-height: 80vh;
485
+ display: flex;
486
+ flex-direction: column;
487
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5);
488
+ overflow: hidden;
489
+ }
490
+ .mcp-modal-header {
491
+ padding: 18px 20px;
492
+ border-bottom: 1px solid rgba(255,255,255,0.07);
493
+ display: flex; align-items: center; justify-content: space-between;
494
+ }
495
+ .mcp-modal-header h3 { font-size: 15px; font-weight: 700; color: var(--text); }
496
+ .mcp-modal-close { background: none; border: none; color: var(--faint); cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 6px; }
497
+ .mcp-modal-close:hover { color: var(--text); background: rgba(255,255,255,0.07); }
498
+ .mcp-modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
499
+ .mcp-section-label { font-size: 10px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .08em; margin: 12px 0 8px; }
500
+ .mcp-item {
501
+ display: flex; align-items: center; gap: 12px;
502
+ padding: 11px 14px;
503
+ background: rgba(255,255,255,0.03);
504
+ border: 1px solid rgba(255,255,255,0.06);
505
+ border-radius: 10px;
506
+ margin-bottom: 6px;
507
+ }
508
+ .mcp-item-icon { font-size: 20px; flex-shrink: 0; }
509
+ .mcp-item-info { flex: 1; min-width: 0; }
510
+ .mcp-item-name { font-size: 13px; font-weight: 600; color: var(--text); }
511
+ .mcp-item-desc { font-size: 11px; color: var(--faint); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
512
+ .mcp-item-status { font-size: 10.5px; color: var(--accent); font-weight: 600; }
513
+ .mcp-item-status.inactive { color: var(--faint); }
514
+ .mcp-install-btn {
515
+ padding: 6px 12px; border-radius: 7px; font-size: 12px; font-weight: 600;
516
+ border: 1px solid rgba(34,211,160,0.3); background: rgba(34,211,160,0.08);
517
+ color: var(--accent); cursor: pointer; transition: all .15s; flex-shrink: 0;
518
+ }
519
+ .mcp-install-btn:hover { background: rgba(34,211,160,0.15); }
520
+ .mcp-install-btn.installed { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.04); color: var(--faint); }
421
521
  .acct-modal {
422
522
  background: var(--surface, #1e293b);
423
523
  border: 1px solid rgba(255,255,255,0.08);
@@ -2807,43 +2907,6 @@
2807
2907
  </div>
2808
2908
  <div class="bg-grid"></div>
2809
2909
 
2810
- <div id="auth-overlay" class="auth-overlay">
2811
- <div class="auth-orb auth-orb-1"></div>
2812
- <div class="auth-orb auth-orb-2"></div>
2813
- <div class="auth-lang-picker lang-picker" id="auth-lang-picker">
2814
- <button class="auth-lang-btn" id="auth-lang-btn" onclick="toggleLangMenu('auth-lang-picker')">🌐 Languages</button>
2815
- <div class="lang-picker-menu" id="auth-lang-picker-menu">
2816
- <div class="lang-option" id="auth-lang-ko" onclick="setLang('ko')">🇰🇷 한국어</div>
2817
- <div class="lang-option" id="auth-lang-en" onclick="setLang('en')">🇺🇸 English</div>
2818
- </div>
2819
- </div>
2820
- <div class="auth-card">
2821
- <div id="login-form">
2822
- <div class="auth-logo"><i class="ti ti-brain"></i></div>
2823
- <h2 class="auth-title" data-i18n="login_title">Lattice AI</h2>
2824
- <p class="auth-subtitle" data-i18n="login_sub">Local AI Workspace — Apple Silicon</p>
2825
- <input class="auth-input" type="email" id="login-email" placeholder="이메일 주소" data-i18n-ph="ph_email">
2826
- <input class="auth-input" type="password" id="login-pw" placeholder="비밀번호" data-i18n-ph="ph_password">
2827
- <button class="auth-submit" onclick="handleLogin()" data-i18n="btn_login">로그인</button>
2828
- <p class="auth-switch"><span data-i18n="no_account">계정이 없으신가요?</span>
2829
- <a href="#" onclick="toggleAuth(true)" data-i18n="go_register">회원가입</a></p>
2830
- </div>
2831
- <div id="register-form" style="display: none;">
2832
- <div class="auth-logo"><i class="ti ti-user-plus"></i></div>
2833
- <h2 class="auth-title" data-i18n="register_title">계정 만들기</h2>
2834
- <p class="auth-subtitle" data-i18n="register_sub">Lattice AI 워크스페이스에 참여하세요</p>
2835
- <input class="auth-input" type="email" id="reg-email" placeholder="이메일 주소" data-i18n-ph="ph_email">
2836
- <input class="auth-input" type="password" id="reg-pw" placeholder="비밀번호 (4자 이상)" data-i18n-ph="ph_new_pw">
2837
- <input class="auth-input" type="password" id="reg-pw2" placeholder="비밀번호 확인" data-i18n-ph="ph_pw_confirm">
2838
- <input class="auth-input" type="text" id="reg-name" placeholder="이름" data-i18n-ph="ph_fullname">
2839
- <input class="auth-input" type="text" id="reg-nickname" placeholder="닉네임" data-i18n-ph="ph_nick">
2840
- <div id="reg-msg" style="font-size:12px;min-height:16px;margin-bottom:4px;"></div>
2841
- <button class="auth-submit" id="reg-submit-btn" onclick="handleRegister()" data-i18n="btn_register">가입하기</button>
2842
- <p class="auth-switch"><span data-i18n="have_account">이미 계정이 있나요?</span>
2843
- <a href="#" onclick="toggleAuth(false)" data-i18n="go_login">로그인</a></p>
2844
- </div>
2845
- </div>
2846
- </div>
2847
2910
 
2848
2911
  <div class="app-layout">
2849
2912
  <!-- Sidebar -->
@@ -2862,6 +2925,12 @@
2862
2925
  <div style="font-size:10.5px;color:var(--faint)">Local Workspace</div>
2863
2926
  </div>
2864
2927
  </div>
2928
+ <div class="sidebar-search">
2929
+ <div class="sidebar-search-wrap">
2930
+ <i class="ti ti-search"></i>
2931
+ <input type="text" id="history-search-input" placeholder="대화 검색..." oninput="onHistorySearch(this.value)">
2932
+ </div>
2933
+ </div>
2865
2934
  <div class="history-container" id="history-container">
2866
2935
  <!-- History items -->
2867
2936
  </div>
@@ -2869,6 +2938,7 @@
2869
2938
  <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> <span data-i18n="admin_dashboard">관리자 대시보드</span></button>
2870
2939
  <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> <span data-i18n="my_status">내 상태 보기</span></button>
2871
2940
  <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
+ <button class="setup-wizard-sidebar-btn" onclick="openMcpModal()"><i class="ti ti-plug-connected"></i> MCP 관리</button>
2872
2942
  <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
2873
2943
  </div>
2874
2944
  </aside>
@@ -2944,6 +3014,19 @@
2944
3014
  </div>
2945
3015
  </div>
2946
3016
 
3017
+ <!-- MCP 관리 모달 -->
3018
+ <div class="mcp-modal-overlay" id="mcp-modal-overlay" onclick="if(event.target===this)closeMcpModal()">
3019
+ <div class="mcp-modal">
3020
+ <div class="mcp-modal-header">
3021
+ <h3><i class="ti ti-plug-connected"></i> MCP 서버 관리</h3>
3022
+ <button class="mcp-modal-close" onclick="closeMcpModal()"><i class="ti ti-x"></i></button>
3023
+ </div>
3024
+ <div class="mcp-modal-body" id="mcp-modal-body">
3025
+ <div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">로딩 중...</div>
3026
+ </div>
3027
+ </div>
3028
+ </div>
3029
+
2947
3030
  <section class="ops-strip" aria-label="workspace status">
2948
3031
  <div class="ops-card primary interactive" onclick="openModelPanel()">
2949
3032
  <div>
@@ -3351,7 +3434,6 @@
3351
3434
  if (typeof acquireVsCodeApi === 'function') {
3352
3435
  vscode = acquireVsCodeApi();
3353
3436
  console.log("VS Code API Acquired");
3354
- document.getElementById('auth-overlay').style.display = 'none'; // VS Code에서는 로그인 건너뜀
3355
3437
  }
3356
3438
  } catch (e) { /* Not in VS Code */ }
3357
3439
 
@@ -3363,80 +3445,12 @@
3363
3445
  }
3364
3446
  });
3365
3447
 
3366
- function toggleAuth(showRegister) {
3367
- document.getElementById('login-form').style.display = showRegister ? 'none' : 'block';
3368
- document.getElementById('register-form').style.display = showRegister ? 'block' : 'none';
3369
- }
3370
-
3371
- async function handleRegister() {
3372
- const email = document.getElementById('reg-email').value.trim();
3373
- const password = document.getElementById('reg-pw').value;
3374
- const password2 = document.getElementById('reg-pw2').value;
3375
- const name = document.getElementById('reg-name').value.trim();
3376
- const nickname = document.getElementById('reg-nickname').value.trim();
3377
- const msg = document.getElementById('reg-msg');
3378
- const btn = document.getElementById('reg-submit-btn');
3379
- const setMsg = (text, isError) => {
3380
- msg.textContent = text;
3381
- msg.style.color = isError ? '#f87171' : '#4ade80';
3382
- };
3383
- if (!email || !password || !password2 || !name || !nickname) return setMsg('모든 항목을 입력해주세요.', true);
3384
- if (password.length < 4) return setMsg('비밀번호는 4자 이상이어야 합니다.', true);
3385
- if (password !== password2) return setMsg('비밀번호가 일치하지 않습니다.', true);
3386
- btn.disabled = true; btn.textContent = '가입 중...';
3387
- try {
3388
- const res = await apiFetch('/register', {
3389
- method: 'POST',
3390
- headers: { 'Content-Type': 'application/json' },
3391
- body: JSON.stringify({ email, password, name, nickname })
3392
- });
3393
- if (res.ok) {
3394
- setMsg('✅ 가입 완료! 로그인 해주세요.', false);
3395
- setTimeout(() => toggleAuth(false), 1200);
3396
- } else {
3397
- const data = await res.json();
3398
- setMsg(data.detail || '가입 실패', true);
3399
- }
3400
- } catch { setMsg('서버 연결 실패', true); }
3401
- finally { btn.disabled = false; btn.textContent = '가입하기'; }
3402
- }
3403
-
3404
- async function handleLogin() {
3405
- const email = document.getElementById('login-email').value;
3406
- const password = document.getElementById('login-pw').value;
3407
- const res = await apiFetch('/login', {
3408
- method: 'POST',
3409
- headers: { 'Content-Type': 'application/json' },
3410
- body: JSON.stringify({ email, password })
3411
- });
3412
- if (res.ok) {
3413
- const data = await res.json();
3414
- currentUserNickname = data.nickname;
3415
- currentUserEmail = data.email;
3416
- localStorage.setItem('ltcai_user_email', currentUserEmail);
3417
- localStorage.setItem('ltcai_user_nickname', currentUserNickname);
3418
- localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
3419
- isAdmin = Boolean(data.is_admin);
3420
- document.getElementById('user-nickname-display').innerText = currentUserNickname;
3421
- const av = document.getElementById('user-avatar-initial');
3422
- if (av) av.textContent = (currentUserNickname || 'G')[0].toUpperCase();
3423
- document.getElementById('admin-btn').style.display = 'flex';
3424
- document.getElementById('security-admin-meta').textContent = isAdmin ? '관리자 권한 있음' : '관리자 대시보드 접근';
3425
- document.getElementById('auth-overlay').style.display = 'none';
3426
- loadVpcStatus();
3427
- restoreCurrentConversation().then(() => {
3428
- if (!chatViewport.querySelector('.message')) {
3429
- addMessage('ai', `반갑습니다, <b>${currentUserNickname}</b>님!`);
3430
- }
3431
- });
3432
- } else { alert("로그인 실패!"); }
3433
- }
3434
-
3435
- function logout() {
3448
+ async function logout() {
3449
+ try { await apiFetch('/logout', { method: 'POST' }); } catch (_) {}
3436
3450
  localStorage.removeItem('ltcai_user_email');
3437
3451
  localStorage.removeItem('ltcai_user_nickname');
3438
3452
  localStorage.removeItem('ltcai_is_admin');
3439
- location.reload();
3453
+ window.location.href = '/account';
3440
3454
  }
3441
3455
 
3442
3456
  const I18N = {
@@ -3461,7 +3475,7 @@
3461
3475
  btn_save: '저장', btn_change: '변경', btn_cancel: '취소',
3462
3476
  // ops 스트립
3463
3477
  vpc_not_set: '설정 안 됨', vpc_click_to_set: '클릭하여 VPC 연결 설정',
3464
- security_monitor: '민감정보 감시', admin_dashboard_access: '관리자 대시보드 접근',
3478
+ security_monitor: '민감정보 감시', admin_dashboard_access: '관리자 대시보드 접근', admin_has_rights: '관리자 권한 있음',
3465
3479
  // 빈 화면
3466
3480
  empty_title: '무엇을 만들까요?',
3467
3481
  empty_sub: '로컬 모델, 이미지 분석, 코드 생성, 프라이빗 VPC — 모든 걸 한 화면에서 이어가세요.',
@@ -3500,7 +3514,7 @@
3500
3514
  btn_save: 'Save', btn_change: 'Change', btn_cancel: 'Cancel',
3501
3515
  // Ops strip
3502
3516
  vpc_not_set: 'Not configured', vpc_click_to_set: 'Click to set up VPC',
3503
- security_monitor: 'Sensitive data monitor', admin_dashboard_access: 'Admin dashboard access',
3517
+ security_monitor: 'Sensitive data monitor', admin_dashboard_access: 'Admin dashboard access', admin_has_rights: 'Has admin rights',
3504
3518
  // Empty state
3505
3519
  empty_title: 'What would you like to build?',
3506
3520
  empty_sub: 'Local models, image analysis, code generation, private VPC — all in one workspace.',
@@ -4051,9 +4065,27 @@
4051
4065
  }
4052
4066
 
4053
4067
  async function openAdminPanel() {
4068
+ if (!isAdmin) {
4069
+ showToast(currentLang === 'ko' ? '관리자 권한이 없습니다.' : 'Admin access required.');
4070
+ return;
4071
+ }
4054
4072
  window.location.href = '/admin';
4055
4073
  }
4056
4074
 
4075
+ function showToast(msg) {
4076
+ let t = document.getElementById('ltcai-toast');
4077
+ if (!t) {
4078
+ t = document.createElement('div');
4079
+ t.id = 'ltcai-toast';
4080
+ t.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:#1e2330;color:#f8fafc;border:1px solid rgba(255,255,255,0.12);border-radius:10px;padding:10px 18px;font-size:13px;font-weight:600;z-index:9999;box-shadow:0 8px 24px rgba(0,0,0,0.4);pointer-events:none;transition:opacity .2s;';
4081
+ document.body.appendChild(t);
4082
+ }
4083
+ t.textContent = msg;
4084
+ t.style.opacity = '1';
4085
+ clearTimeout(t._timer);
4086
+ t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2200);
4087
+ }
4088
+
4057
4089
  function closeAdminPanel() {
4058
4090
  document.getElementById('admin-overlay').style.display = 'none';
4059
4091
  }
@@ -5012,27 +5044,59 @@
5012
5044
  }
5013
5045
  }
5014
5046
 
5047
+ function renderHistoryItems(conversations) {
5048
+ if (!conversations.length) {
5049
+ historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
5050
+ return;
5051
+ }
5052
+ historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
5053
+ <div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
5054
+ <i class="ti ti-message-2"></i>
5055
+ <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis">${escapeHtml(item.title || '새 대화')}</span>
5056
+ <span class="history-item-del" onclick="event.stopPropagation();deleteConversation('${escapeHtml(item.id)}')"><i class="ti ti-trash"></i></span>
5057
+ </div>
5058
+ `).join('');
5059
+ historyContainer.querySelectorAll('.history-item').forEach(item => {
5060
+ item.onclick = () => openConversation(item.dataset.conversationId);
5061
+ });
5062
+ }
5063
+
5015
5064
  async function loadHistory() {
5016
5065
  try {
5017
5066
  const conversations = await fetchConversations();
5018
- if (!conversations.length) {
5019
- historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
5020
- return [];
5021
- }
5022
- historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
5023
- <div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
5024
- <i class="ti ti-message-2"></i>
5025
- <span>${escapeHtml(item.title || '새 대화')}</span>
5026
- </div>
5027
- `).join('');
5028
- historyContainer.querySelectorAll('.history-item').forEach(item => {
5029
- item.onclick = () => openConversation(item.dataset.conversationId);
5030
- });
5067
+ renderHistoryItems(conversations);
5031
5068
  return conversations;
5032
5069
  } catch (e) { }
5033
5070
  return [];
5034
5071
  }
5035
5072
 
5073
+ let _searchDebounce = null;
5074
+ async function onHistorySearch(q) {
5075
+ clearTimeout(_searchDebounce);
5076
+ if (!q.trim()) { loadHistory(); return; }
5077
+ _searchDebounce = setTimeout(async () => {
5078
+ try {
5079
+ const res = await apiFetch(`/history/search?q=${encodeURIComponent(q)}`);
5080
+ if (!res.ok) return;
5081
+ const data = await res.json();
5082
+ const results = (data.results || []).map(r => ({
5083
+ id: r.conversation_id,
5084
+ title: r.title || '새 대화',
5085
+ }));
5086
+ renderHistoryItems(results);
5087
+ } catch {}
5088
+ }, 300);
5089
+ }
5090
+
5091
+ async function deleteConversation(conversationId) {
5092
+ if (!confirm('이 대화를 삭제할까요?')) return;
5093
+ try {
5094
+ await apiFetch(`/history/conversations/${encodeURIComponent(conversationId)}`, { method: 'DELETE' });
5095
+ if (currentConversationId === conversationId) startNewConversation();
5096
+ loadHistory();
5097
+ } catch {}
5098
+ }
5099
+
5036
5100
  async function restoreCurrentConversation() {
5037
5101
  const conversations = await loadHistory();
5038
5102
  if (conversations.some(item => item.id === currentConversationId)) {
@@ -5546,6 +5610,32 @@
5546
5610
 
5547
5611
  document.getElementById('new-chat-btn').onclick = startNewChat;
5548
5612
  applyI18n();
5613
+
5614
+ // Session check — redirect to /account if not logged in
5615
+ (async function restoreSession() {
5616
+ try {
5617
+ const res = await apiFetch('/account/profile');
5618
+ if (res.ok) {
5619
+ const data = await res.json();
5620
+ currentUserEmail = data.email;
5621
+ currentUserNickname = data.nickname || data.name || data.email;
5622
+ isAdmin = Boolean(data.is_admin);
5623
+ localStorage.setItem('ltcai_user_email', currentUserEmail);
5624
+ localStorage.setItem('ltcai_user_nickname', currentUserNickname);
5625
+ localStorage.setItem('ltcai_is_admin', isAdmin ? 'true' : 'false');
5626
+ document.getElementById('user-nickname-display').innerText = currentUserNickname;
5627
+ const av = document.getElementById('user-avatar-initial');
5628
+ if (av) av.textContent = (currentUserNickname || 'G')[0].toUpperCase();
5629
+ document.getElementById('admin-btn').style.display = 'flex';
5630
+ document.getElementById('security-admin-meta').textContent = isAdmin ? t('admin_has_rights') : t('admin_dashboard_access');
5631
+ } else {
5632
+ window.location.href = '/account';
5633
+ }
5634
+ } catch (_) {
5635
+ window.location.href = '/account';
5636
+ }
5637
+ })();
5638
+
5549
5639
  loadModelStatus();
5550
5640
  loadVpcStatus();
5551
5641
  restoreCurrentConversation();
@@ -6073,6 +6163,91 @@
6073
6163
  _footBtns(`<button class="wbtn wbtn-primary" onclick="closeSetupWizard();loadModelStatus()">완료 ✓</button>`);
6074
6164
  }
6075
6165
  </script>
6166
+
6167
+ <script>
6168
+ // ── MCP 관리 모달 ────────────────────────────────────────────────────────
6169
+ async function openMcpModal() {
6170
+ document.getElementById('mcp-modal-overlay').classList.add('open');
6171
+ await renderMcpModal();
6172
+ }
6173
+
6174
+ function closeMcpModal() {
6175
+ document.getElementById('mcp-modal-overlay').classList.remove('open');
6176
+ }
6177
+
6178
+ async function renderMcpModal() {
6179
+ const body = document.getElementById('mcp-modal-body');
6180
+ body.innerHTML = '<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">로딩 중...</div>';
6181
+ try {
6182
+ const [installedRes, toolsRes] = await Promise.all([
6183
+ apiFetch('/mcp/installed'),
6184
+ apiFetch('/mcp/tools'),
6185
+ ]);
6186
+ const installed = installedRes.ok ? await installedRes.json() : {};
6187
+ const allTools = toolsRes.ok ? await toolsRes.json() : [];
6188
+
6189
+ const installedIds = new Set(Object.keys(installed));
6190
+ const installedItems = allTools.filter(t => installedIds.has(t.id));
6191
+ const availableItems = allTools.filter(t => !installedIds.has(t.id));
6192
+
6193
+ let html = '';
6194
+
6195
+ if (installedItems.length) {
6196
+ html += '<div class="mcp-section-label">설치됨</div>';
6197
+ html += installedItems.map(mcp => `
6198
+ <div class="mcp-item">
6199
+ <div class="mcp-item-icon">${mcp.icon || '🔌'}</div>
6200
+ <div class="mcp-item-info">
6201
+ <div class="mcp-item-name">${escapeHtml(mcp.name || mcp.id)}</div>
6202
+ <div class="mcp-item-desc">${escapeHtml(mcp.description || '')}</div>
6203
+ </div>
6204
+ <span class="mcp-item-status">활성</span>
6205
+ </div>
6206
+ `).join('');
6207
+ }
6208
+
6209
+ if (availableItems.length) {
6210
+ html += '<div class="mcp-section-label">설치 가능</div>';
6211
+ html += availableItems.map(mcp => `
6212
+ <div class="mcp-item" id="mcp-item-${escapeHtml(mcp.id)}">
6213
+ <div class="mcp-item-icon">${mcp.icon || '🔌'}</div>
6214
+ <div class="mcp-item-info">
6215
+ <div class="mcp-item-name">${escapeHtml(mcp.name || mcp.id)}</div>
6216
+ <div class="mcp-item-desc">${escapeHtml(mcp.description || '')}</div>
6217
+ </div>
6218
+ <button class="mcp-install-btn" onclick="installMcp('${escapeHtml(mcp.id)}')">설치</button>
6219
+ </div>
6220
+ `).join('');
6221
+ }
6222
+
6223
+ if (!html) html = '<div style="color:var(--faint);font-size:13px;text-align:center;padding:24px">사용 가능한 MCP 서버가 없습니다.</div>';
6224
+ body.innerHTML = html;
6225
+ } catch (e) {
6226
+ body.innerHTML = `<div style="color:#ff6b6b;font-size:13px;text-align:center;padding:24px">로드 실패: ${escapeHtml(e.message)}</div>`;
6227
+ }
6228
+ }
6229
+
6230
+ async function installMcp(id) {
6231
+ const btn = document.querySelector(`#mcp-item-${CSS.escape(id)} .mcp-install-btn`);
6232
+ if (btn) { btn.disabled = true; btn.textContent = '설치 중...'; }
6233
+ try {
6234
+ const res = await apiFetch('/mcp/install', {
6235
+ method: 'POST',
6236
+ headers: { 'Content-Type': 'application/json' },
6237
+ body: JSON.stringify({ id }),
6238
+ });
6239
+ if (res.ok) {
6240
+ await renderMcpModal();
6241
+ } else {
6242
+ const d = await res.json().catch(() => ({}));
6243
+ if (btn) { btn.disabled = false; btn.textContent = '설치'; }
6244
+ alert(d.detail || '설치 실패');
6245
+ }
6246
+ } catch {
6247
+ if (btn) { btn.disabled = false; btn.textContent = '설치'; }
6248
+ }
6249
+ }
6250
+ </script>
6076
6251
  </body>
6077
6252
 
6078
6253
  </html>