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.
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.2",
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",
@@ -32,8 +32,8 @@
32
32
  "telegram_bot.py",
33
33
  "tools.py",
34
34
  "codex_telegram_bot.py",
35
- "static/index.html",
36
- "static/indexd.html",
35
+ "static/account.html",
36
+ "static/chat.html",
37
37
  "static/admin.html",
38
38
  "requirements.txt",
39
39
  "README.md"
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)
@@ -1295,7 +1420,9 @@ async def get_profile(request: Request):
1295
1420
  user = users.get(email)
1296
1421
  if not user:
1297
1422
  raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1298
- return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", "")}
1423
+ role = get_user_role(email, users)
1424
+ return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
1425
+ "role": role, "is_admin": role == "admin"}
1299
1426
 
1300
1427
  @app.get("/admin/summary")
1301
1428
  async def admin_summary(request: Request):
@@ -1402,20 +1529,20 @@ INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
1402
1529
 
1403
1530
  @app.get("/")
1404
1531
  async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
1405
- """기본은 즉시 진입. 필요초대 링크 게이트를 env로 활성화할 수 있습니다."""
1532
+ """로그인/회원가입 페이지. 초대 게이트 활성화 코드 검증 진입."""
1406
1533
  if not INVITE_GATE_ENABLED:
1407
- return FileResponse(STATIC_DIR / "indexd.html")
1534
+ return FileResponse(STATIC_DIR / "account.html")
1408
1535
 
1409
1536
  # 1. 이미 쿠키로 인증된 경우
1410
1537
  if authorized == "true":
1411
- return FileResponse(STATIC_DIR / "indexd.html")
1538
+ return FileResponse(STATIC_DIR / "account.html")
1412
1539
 
1413
1540
  # 2. 초대 코드가 일치하는 경우 (최초 진입)
1414
1541
  if code == INVITE_CODE:
1415
- response = FileResponse(STATIC_DIR / "indexd.html")
1542
+ response = FileResponse(STATIC_DIR / "account.html")
1416
1543
  response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
1417
1544
  return response
1418
-
1545
+
1419
1546
  # 3. 인증 실패 시 차단 화면
1420
1547
  return HTMLResponse(content=f"""
1421
1548
  <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
@@ -1429,6 +1556,11 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
1429
1556
  """, status_code=403)
1430
1557
 
1431
1558
 
1559
+ @app.get("/chat")
1560
+ async def chat_page(request: Request):
1561
+ return FileResponse(STATIC_DIR / "chat.html")
1562
+
1563
+
1432
1564
  @app.get("/admin")
1433
1565
  async def admin_page():
1434
1566
  admin_path = STATIC_DIR / "admin.html"
@@ -2398,6 +2530,23 @@ async def delete_history(request: Request, keep_last: int = 0):
2398
2530
  require_user(request)
2399
2531
  return clear_history(keep_last)
2400
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
+
2401
2550
 
2402
2551
  async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
2403
2552
  full_response = ""