ltcai 0.1.3 → 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.
package/README.md CHANGED
@@ -10,6 +10,35 @@ LTCAI # → http://localhost:4825
10
10
 
11
11
  ---
12
12
 
13
+ ## v0.1.4 변경사항
14
+
15
+ ### Added
16
+ - 세션 영속성 — 서버 재시작 후에도 로그인 유지
17
+ - SSO 로그인 — Entra ID / Okta OIDC 지원 (`OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`)
18
+ - 채팅 히스토리 검색 — 사이드바 검색창으로 대화 내용 키워드 검색
19
+ - 대화 삭제 — 사이드바 각 대화에 삭제 버튼
20
+ - MCP 서버 관리 UI — 사이드바 "MCP 관리" 버튼으로 설치/목록 모달
21
+ - VS Code 인라인 Diff 뷰 — Edit Selection 결과를 diff로 먼저 확인 후 Apply/Discard
22
+ - VS Code 현재 파일 첨부 — `Lattice AI: Attach Current File to Chat` 명령 추가
23
+
24
+ ---
25
+
26
+ ## v0.1.3 변경사항
27
+
28
+ ### Added
29
+ - 프로필 수정 (`PATCH /account/profile`) — 이름·닉네임 변경
30
+ - 회원가입 폼 — 비밀번호 확인 필드, 인라인 에러 메시지
31
+ - 어드민 패널 초대 링크 섹션 — 원클릭 복사
32
+ - 어드민 대시보드 메시지 활동 차트 (Chart.js, 최근 14일)
33
+ - 웹 UI 한국어 / 영어 전환 (`🌐 Languages` 버튼, localStorage 저장)
34
+
35
+ ### Fixed
36
+ - 로그아웃 시 `/logout` API 호출로 서버 세션 쿠키 정상 만료
37
+ - 인증(`account.html`)과 채팅(`chat.html`) UI 분리 — 레거시 파일 제거
38
+ - 채팅 헤더에서 언어 선택 드롭다운이 ops-strip을 가리는 문제 수정
39
+
40
+ ---
41
+
13
42
  ## 아키텍처
14
43
 
15
44
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "bin": {
6
6
  "ltcai": "bin/ltcai.js",
package/requirements.txt CHANGED
@@ -9,3 +9,4 @@ openpyxl
9
9
  python-pptx
10
10
  python-multipart
11
11
  keyring
12
+ authlib
package/server.py CHANGED
@@ -175,6 +175,31 @@ PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_
175
175
  LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
176
176
  LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
177
177
 
178
+ # ── SSO / OIDC config ─────────────────────────────────────────────────────────
179
+ SSO_DISCOVERY_URL = env_value("OIDC_DISCOVERY_URL", "")
180
+ SSO_CLIENT_ID = env_value("OIDC_CLIENT_ID", "")
181
+ SSO_CLIENT_SECRET = env_value("OIDC_CLIENT_SECRET", "")
182
+ SSO_REDIRECT_URI = env_value("OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback")
183
+ SSO_PROVIDER_NAME = env_value("OIDC_PROVIDER_NAME", "SSO")
184
+ _sso_discovery_cache: Optional[Dict] = None
185
+ _sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
186
+
187
+ async def _get_sso_discovery() -> Optional[Dict]:
188
+ global _sso_discovery_cache
189
+ if _sso_discovery_cache:
190
+ return _sso_discovery_cache
191
+ if not SSO_DISCOVERY_URL:
192
+ return None
193
+ try:
194
+ import httpx as _httpx
195
+ async with _httpx.AsyncClient() as c:
196
+ r = await c.get(SSO_DISCOVERY_URL, timeout=10)
197
+ _sso_discovery_cache = r.json()
198
+ except Exception as e:
199
+ logging.warning("SSO discovery failed: %s", e)
200
+ return None
201
+ return _sso_discovery_cache
202
+
178
203
  # ── Password hashing (stdlib scrypt, no extra deps) ────────────────────────────
179
204
  def hash_password(password: str) -> str:
180
205
  salt = secrets.token_hex(16)
@@ -199,27 +224,54 @@ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict
199
224
  return True
200
225
  return False
201
226
 
202
- # ── Session store (in-memory, clears on restart) ──────────────────────────────
227
+ # ── Session store (file-backed, survives restarts) ────────────────────────────
203
228
  _SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
204
- _sessions: Dict[str, tuple] = {} # token → (email, created_at)
229
+ _sessions_lock = threading.Lock()
230
+
231
+ def _sessions_file() -> Path:
232
+ return DATA_DIR / "sessions.json"
233
+
234
+ def _load_sessions() -> Dict[str, tuple]:
235
+ try:
236
+ f = _sessions_file()
237
+ if f.exists():
238
+ raw = json.loads(f.read_text())
239
+ return {k: tuple(v) for k, v in raw.items()}
240
+ except Exception:
241
+ pass
242
+ return {}
243
+
244
+ def _persist_sessions(sessions: Dict[str, tuple]) -> None:
245
+ try:
246
+ _sessions_file().write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
247
+ except Exception:
248
+ pass
249
+
250
+ _sessions: Dict[str, tuple] = _load_sessions()
205
251
 
206
252
  def create_session(email: str) -> str:
207
253
  token = secrets.token_urlsafe(32)
208
- _sessions[token] = (email, time.time())
254
+ with _sessions_lock:
255
+ _sessions[token] = (email, time.time())
256
+ _persist_sessions(_sessions)
209
257
  return token
210
258
 
211
259
  def get_session_email(token: str) -> Optional[str]:
212
- entry = _sessions.get(token)
213
- if entry is None:
214
- return None
215
- email, created_at = entry
216
- if time.time() - created_at > _SESSION_TTL:
217
- _sessions.pop(token, None)
218
- return None
219
- return email
260
+ with _sessions_lock:
261
+ entry = _sessions.get(token)
262
+ if entry is None:
263
+ return None
264
+ email, created_at = entry
265
+ if time.time() - created_at > _SESSION_TTL:
266
+ _sessions.pop(token, None)
267
+ _persist_sessions(_sessions)
268
+ return None
269
+ return email
220
270
 
221
271
  def invalidate_session(token: str) -> None:
222
- _sessions.pop(token, None)
272
+ with _sessions_lock:
273
+ _sessions.pop(token, None)
274
+ _persist_sessions(_sessions)
223
275
 
224
276
  # ── User Management Logic ──────────────────────────────────────────────────
225
277
  BASE_DIR = Path(__file__).resolve().parent
@@ -1232,6 +1284,79 @@ async def login(req: UserLogin):
1232
1284
  response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 7)
1233
1285
  return response
1234
1286
 
1287
+ @app.get("/auth/sso/config")
1288
+ async def sso_config():
1289
+ enabled = bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET)
1290
+ return {"enabled": enabled, "provider_name": SSO_PROVIDER_NAME if enabled else ""}
1291
+
1292
+ @app.get("/auth/sso/login")
1293
+ async def sso_login():
1294
+ from urllib.parse import urlencode
1295
+ from fastapi.responses import RedirectResponse as _Redirect
1296
+ discovery = await _get_sso_discovery()
1297
+ if not discovery:
1298
+ raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
1299
+ state = secrets.token_urlsafe(16)
1300
+ _sso_states[state] = time.time()
1301
+ params = urlencode({
1302
+ "client_id": SSO_CLIENT_ID,
1303
+ "response_type": "code",
1304
+ "redirect_uri": SSO_REDIRECT_URI,
1305
+ "scope": "openid email profile",
1306
+ "state": state,
1307
+ })
1308
+ return _Redirect(f"{discovery['authorization_endpoint']}?{params}")
1309
+
1310
+ @app.get("/auth/sso/callback")
1311
+ async def sso_callback(code: str = "", state: str = "", error: str = ""):
1312
+ from fastapi.responses import RedirectResponse as _Redirect
1313
+ import base64 as _b64
1314
+ if error:
1315
+ return _Redirect(f"/?sso_error={error}")
1316
+ ts = _sso_states.pop(state, None)
1317
+ if ts is None or time.time() - ts > 300:
1318
+ raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
1319
+ discovery = await _get_sso_discovery()
1320
+ if not discovery:
1321
+ raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
1322
+ import httpx as _httpx
1323
+ async with _httpx.AsyncClient() as c:
1324
+ r = await c.post(discovery["token_endpoint"], data={
1325
+ "grant_type": "authorization_code",
1326
+ "code": code,
1327
+ "redirect_uri": SSO_REDIRECT_URI,
1328
+ "client_id": SSO_CLIENT_ID,
1329
+ "client_secret": SSO_CLIENT_SECRET,
1330
+ }, headers={"Accept": "application/json"}, timeout=15)
1331
+ tokens = r.json()
1332
+ id_token = tokens.get("id_token")
1333
+ if not id_token:
1334
+ raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
1335
+ # Decode JWT payload (no signature verification — trust IdP redirect)
1336
+ padded = id_token.split(".")[1] + "=="
1337
+ payload = json.loads(_b64.urlsafe_b64decode(padded))
1338
+ email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
1339
+ if not email:
1340
+ raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
1341
+ users = load_users()
1342
+ if email not in users:
1343
+ is_first = len(users) == 0
1344
+ users[email] = {
1345
+ "password": "",
1346
+ "name": payload.get("name", email.split("@")[0]),
1347
+ "nickname": payload.get("given_name", email.split("@")[0]),
1348
+ "role": "admin" if is_first else "user",
1349
+ "disabled": False,
1350
+ "sso": True,
1351
+ }
1352
+ save_users(users)
1353
+ if users[email].get("disabled"):
1354
+ raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
1355
+ token = create_session(email)
1356
+ resp = _Redirect("/chat", status_code=302)
1357
+ resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
1358
+ return resp
1359
+
1235
1360
  @app.post("/logout")
1236
1361
  async def logout(request: Request):
1237
1362
  token = _extract_bearer_token(request)
@@ -2405,6 +2530,23 @@ async def delete_history(request: Request, keep_last: int = 0):
2405
2530
  require_user(request)
2406
2531
  return clear_history(keep_last)
2407
2532
 
2533
+ @app.get("/history/search")
2534
+ async def search_history(q: str, request: Request):
2535
+ """키워드로 채팅 히스토리를 검색합니다."""
2536
+ require_user(request)
2537
+ if not q or not q.strip():
2538
+ return {"results": [], "query": q}
2539
+ q_lower = q.strip().lower()
2540
+ history = get_history()
2541
+ matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
2542
+ grouped: Dict[str, Dict] = {}
2543
+ for item in matches:
2544
+ cid = item.get("conversation_id") or "legacy"
2545
+ if cid not in grouped:
2546
+ grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
2547
+ grouped[cid]["messages"].append(item)
2548
+ return {"results": list(grouped.values())[-30:], "query": q}
2549
+
2408
2550
 
2409
2551
  async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
2410
2552
  full_response = ""
@@ -164,6 +164,34 @@
164
164
  .switch a { color: var(--accent); text-decoration: none; font-weight: 700; }
165
165
  .switch a:hover { text-decoration: underline; }
166
166
 
167
+ .sso-divider {
168
+ display: flex; align-items: center; gap: 10px;
169
+ margin: 14px 0 10px;
170
+ color: var(--faint); font-size: 11.5px;
171
+ }
172
+ .sso-divider::before, .sso-divider::after {
173
+ content: ''; flex: 1;
174
+ height: 1px; background: rgba(255,255,255,0.07);
175
+ }
176
+ .sso-btn {
177
+ width: 100%;
178
+ padding: 12px;
179
+ background: rgba(255,255,255,0.04);
180
+ border: 1px solid rgba(255,255,255,0.1);
181
+ color: var(--text);
182
+ border-radius: 10px;
183
+ cursor: pointer;
184
+ font-weight: 600;
185
+ font-size: 13.5px;
186
+ font-family: inherit;
187
+ display: flex; align-items: center; justify-content: center; gap: 8px;
188
+ transition: all .18s;
189
+ }
190
+ .sso-btn:hover {
191
+ background: rgba(255,255,255,0.08);
192
+ border-color: rgba(34,211,160,0.3);
193
+ }
194
+
167
195
  .msg {
168
196
  font-size: 12px;
169
197
  min-height: 18px;
@@ -238,6 +266,13 @@
238
266
  <input class="input" type="password" id="login-pw" placeholder="비밀번호" onkeydown="if(event.key==='Enter')doLogin()">
239
267
  <div class="msg" id="login-msg"></div>
240
268
  <button class="submit" id="login-btn" onclick="doLogin()" data-ko="로그인" data-en="Log in">로그인</button>
269
+ <div id="sso-section" style="display:none;">
270
+ <div class="sso-divider" id="sso-divider-text">또는</div>
271
+ <button class="sso-btn" id="sso-btn" onclick="doSSOLogin()">
272
+ <i class="ti ti-building"></i>
273
+ <span id="sso-btn-label">SSO로 로그인</span>
274
+ </button>
275
+ </div>
241
276
  <p class="switch" id="login-switch">
242
277
  <span id="no-account-text">계정이 없으신가요?</span>
243
278
  <a href="#" onclick="showSection('register');return false;" id="go-register-link">회원가입</a>
@@ -281,6 +316,7 @@
281
316
  err_fill: '모든 항목을 입력해주세요.',
282
317
  err_login_fail: '이메일 또는 비밀번호가 틀렸습니다.',
283
318
  err_server: '서버 연결 실패',
319
+ sso_divider: '또는', sso_btn: '로 로그인',
284
320
  },
285
321
  en: {
286
322
  login_title: 'Lattice AI', login_sub: 'Local AI Workspace — Apple Silicon',
@@ -294,6 +330,7 @@
294
330
  err_fill: 'Please fill in all fields.',
295
331
  err_login_fail: 'Invalid email or password.',
296
332
  err_server: 'Server connection failed',
333
+ sso_divider: 'or', sso_btn: 'Sign in with',
297
334
  }
298
335
  };
299
336
 
@@ -319,12 +356,33 @@
319
356
  document.getElementById('reg-name').placeholder = t('ph_name');
320
357
  document.getElementById('reg-nick').placeholder = t('ph_nick');
321
358
  document.getElementById('lang-label').textContent = lang === 'ko' ? '한국어' : 'English';
359
+ document.getElementById('sso-divider-text').textContent = t('sso_divider');
360
+ const ssoName = window._ssoProviderName || 'SSO';
361
+ document.getElementById('sso-btn-label').textContent =
362
+ lang === 'ko' ? `${ssoName}${t('sso_btn')}` : `${t('sso_btn')} ${ssoName}`;
322
363
  ['ko', 'en'].forEach(l => {
323
364
  const el = document.getElementById(`opt-${l}`);
324
365
  if (el) el.classList.toggle('active', l === lang);
325
366
  });
326
367
  }
327
368
 
369
+ async function initSSO() {
370
+ try {
371
+ const res = await apiFetch('/auth/sso/config');
372
+ if (!res.ok) return;
373
+ const cfg = await res.json();
374
+ if (cfg.enabled) {
375
+ window._ssoProviderName = cfg.provider_name;
376
+ document.getElementById('sso-section').style.display = '';
377
+ applyI18n();
378
+ }
379
+ } catch {}
380
+ }
381
+
382
+ function doSSOLogin() {
383
+ window.location.href = '/auth/sso/login';
384
+ }
385
+
328
386
  function toggleLang() {
329
387
  const m = document.getElementById('lang-menu');
330
388
  m.classList.toggle('open');
@@ -436,6 +494,8 @@
436
494
  if (r.ok) window.location.href = '/chat';
437
495
  }).catch(() => {});
438
496
 
497
+ initSSO();
498
+
439
499
  // Handle invite code in URL
440
500
  const urlCode = new URLSearchParams(window.location.search).get('code');
441
501
  if (urlCode) {
package/static/chat.html CHANGED
@@ -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;
@@ -420,6 +462,62 @@
420
462
  justify-content: center;
421
463
  }
422
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); }
423
521
  .acct-modal {
424
522
  background: var(--surface, #1e293b);
425
523
  border: 1px solid rgba(255,255,255,0.08);
@@ -2827,6 +2925,12 @@
2827
2925
  <div style="font-size:10.5px;color:var(--faint)">Local Workspace</div>
2828
2926
  </div>
2829
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>
2830
2934
  <div class="history-container" id="history-container">
2831
2935
  <!-- History items -->
2832
2936
  </div>
@@ -2834,6 +2938,7 @@
2834
2938
  <button id="admin-btn" class="admin-btn" onclick="openAdminPanel()"><i class="ti ti-shield-lock"></i> <span data-i18n="admin_dashboard">관리자 대시보드</span></button>
2835
2939
  <button class="status-btn" onclick="openStatusPanel()"><i class="ti ti-info-circle"></i> <span data-i18n="my_status">내 상태 보기</span></button>
2836
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>
2837
2942
  <button id="new-chat-btn" class="new-chat-btn"><i class="ti ti-plus"></i> New Chat</button>
2838
2943
  </div>
2839
2944
  </aside>
@@ -2909,6 +3014,19 @@
2909
3014
  </div>
2910
3015
  </div>
2911
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
+
2912
3030
  <section class="ops-strip" aria-label="workspace status">
2913
3031
  <div class="ops-card primary interactive" onclick="openModelPanel()">
2914
3032
  <div>
@@ -4926,27 +5044,59 @@
4926
5044
  }
4927
5045
  }
4928
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
+
4929
5064
  async function loadHistory() {
4930
5065
  try {
4931
5066
  const conversations = await fetchConversations();
4932
- if (!conversations.length) {
4933
- historyContainer.innerHTML = '<div class="history-section-label">CHATS</div><div class="history-empty">아직 저장된 대화가 없습니다.</div>';
4934
- return [];
4935
- }
4936
- historyContainer.innerHTML = '<div class="history-section-label">CHATS</div>' + conversations.map(item => `
4937
- <div class="history-item ${item.id === currentConversationId ? 'active' : ''}" data-conversation-id="${escapeHtml(item.id)}" title="${escapeHtml(item.title || '')}">
4938
- <i class="ti ti-message-2"></i>
4939
- <span>${escapeHtml(item.title || '새 대화')}</span>
4940
- </div>
4941
- `).join('');
4942
- historyContainer.querySelectorAll('.history-item').forEach(item => {
4943
- item.onclick = () => openConversation(item.dataset.conversationId);
4944
- });
5067
+ renderHistoryItems(conversations);
4945
5068
  return conversations;
4946
5069
  } catch (e) { }
4947
5070
  return [];
4948
5071
  }
4949
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
+
4950
5100
  async function restoreCurrentConversation() {
4951
5101
  const conversations = await loadHistory();
4952
5102
  if (conversations.some(item => item.id === currentConversationId)) {
@@ -6013,6 +6163,91 @@
6013
6163
  _footBtns(`<button class="wbtn wbtn-primary" onclick="closeSetupWizard();loadModelStatus()">완료 ✓</button>`);
6014
6164
  }
6015
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>
6016
6251
  </body>
6017
6252
 
6018
6253
  </html>