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 +29 -0
- package/package.json +3 -3
- package/requirements.txt +1 -0
- package/server.py +167 -18
- package/static/account.html +509 -0
- package/static/admin.html +214 -67
- package/static/{indexd.html → chat.html} +299 -124
- package/static/index.html +0 -270
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
|
+
"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/
|
|
36
|
-
"static/
|
|
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
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 (
|
|
227
|
+
# ── Session store (file-backed, survives restarts) ────────────────────────────
|
|
203
228
|
_SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
1532
|
+
"""로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
|
|
1406
1533
|
if not INVITE_GATE_ENABLED:
|
|
1407
|
-
return FileResponse(STATIC_DIR / "
|
|
1534
|
+
return FileResponse(STATIC_DIR / "account.html")
|
|
1408
1535
|
|
|
1409
1536
|
# 1. 이미 쿠키로 인증된 경우
|
|
1410
1537
|
if authorized == "true":
|
|
1411
|
-
return FileResponse(STATIC_DIR / "
|
|
1538
|
+
return FileResponse(STATIC_DIR / "account.html")
|
|
1412
1539
|
|
|
1413
1540
|
# 2. 초대 코드가 일치하는 경우 (최초 진입)
|
|
1414
1541
|
if code == INVITE_CODE:
|
|
1415
|
-
response = FileResponse(STATIC_DIR / "
|
|
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 = ""
|