ltcai 1.3.0 → 1.4.0

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.
@@ -0,0 +1,331 @@
1
+ """Local permission request and approval routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import secrets
9
+ import threading
10
+ import time
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Dict, Optional, Tuple
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+
17
+
18
+ _PERMISSION_ACTION_LABELS = {
19
+ "list": "폴더 목록 보기",
20
+ "read": "파일 읽기",
21
+ "write": "파일 쓰기",
22
+ }
23
+
24
+
25
+ class PermissionGateway:
26
+ """Shared permission state used by local-file and knowledge routers."""
27
+
28
+ def __init__(self, *, config, data_dir: Path, require_admin, get_current_user) -> None:
29
+ self.require_admin = require_admin
30
+ self.get_current_user = get_current_user
31
+ self.local_approval_ttl_seconds = 5 * 60
32
+ self.local_approval_lock = threading.Lock()
33
+ self.local_approvals: Dict[str, Dict[str, object]] = {}
34
+ self.discord_permission_webhook_url = config.discord_permission_webhook
35
+ self.discord_bot_token = config.discord_bot_token
36
+ self.discord_permission_channel = config.discord_permission_channel
37
+ self.permission_monitor_secret = config.permission_monitor_secret
38
+ self.perm_queue_file = data_dir / "permission_queue.json"
39
+
40
+ def _perm_queue_write(self, token: str, record: Dict[str, object]) -> None:
41
+ try:
42
+ queue: Dict = {}
43
+ if self.perm_queue_file.exists():
44
+ try:
45
+ queue = json.loads(self.perm_queue_file.read_text(encoding="utf-8"))
46
+ except Exception:
47
+ queue = {}
48
+ queue[token] = {**record, "notified": False}
49
+ self.perm_queue_file.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
50
+ except Exception as exc:
51
+ logging.warning("perm_queue_write failed: %s", exc)
52
+
53
+ def _perm_queue_remove(self, token: str) -> None:
54
+ try:
55
+ if not self.perm_queue_file.exists():
56
+ return
57
+ queue: Dict = json.loads(self.perm_queue_file.read_text(encoding="utf-8"))
58
+ queue.pop(token, None)
59
+ self.perm_queue_file.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
60
+ except Exception as exc:
61
+ logging.warning("perm_queue_remove failed: %s", exc)
62
+
63
+ @staticmethod
64
+ def normalize_local_path_for_approval(path: str) -> str:
65
+ return str(Path(path).expanduser().resolve())
66
+
67
+ @staticmethod
68
+ def content_fingerprint(content: str = "") -> str:
69
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
70
+
71
+ def _notify_discord_permission_sync(self, token: str, path: str, action: str, user_email: str) -> None:
72
+ sent = False
73
+ if self.discord_bot_token and self.discord_permission_channel:
74
+ action_label = _PERMISSION_ACTION_LABELS.get(action, action)
75
+ expires_at_iso = time.strftime(
76
+ "%Y-%m-%d %H:%M:%S UTC",
77
+ time.gmtime(time.time() + self.local_approval_ttl_seconds),
78
+ )
79
+ msg = (
80
+ f"🔐 **파일 접근 권한 요청**\n"
81
+ f"**경로:** `{path}`\n"
82
+ f"**작업:** {action_label}\n"
83
+ f"**요청자:** {user_email}\n"
84
+ f"**토큰:** `{token}`\n"
85
+ f"**만료:** {expires_at_iso}\n\n"
86
+ f"승인하려면 `승인 {token[:8]}` / 거부하려면 `거부 {token[:8]}` 라고 답장하세요."
87
+ )
88
+ payload = json.dumps({"content": msg}, ensure_ascii=False).encode("utf-8")
89
+ try:
90
+ req = urllib.request.Request(
91
+ f"https://discord.com/api/v10/channels/{self.discord_permission_channel}/messages",
92
+ data=payload,
93
+ headers={
94
+ "Content-Type": "application/json",
95
+ "Authorization": f"Bot {self.discord_bot_token}",
96
+ },
97
+ method="POST",
98
+ )
99
+ with urllib.request.urlopen(req, timeout=5):
100
+ pass
101
+ sent = True
102
+ except Exception as exc:
103
+ logging.warning("Discord bot permission notify failed: %s", exc)
104
+
105
+ if not sent and self.discord_permission_webhook_url:
106
+ action_label = _PERMISSION_ACTION_LABELS.get(action, action)
107
+ expires_at_iso = time.strftime(
108
+ "%Y-%m-%d %H:%M:%S UTC",
109
+ time.gmtime(time.time() + self.local_approval_ttl_seconds),
110
+ )
111
+ payload = json.dumps(
112
+ {
113
+ "embeds": [
114
+ {
115
+ "title": "🔐 파일 접근 권한 요청",
116
+ "color": 0xFF9900,
117
+ "fields": [
118
+ {"name": "경로", "value": f"`{path}`", "inline": False},
119
+ {"name": "작업", "value": action_label, "inline": True},
120
+ {"name": "요청자", "value": user_email, "inline": True},
121
+ {"name": "토큰", "value": f"`{token}`", "inline": False},
122
+ {"name": "만료", "value": expires_at_iso, "inline": True},
123
+ ],
124
+ "footer": {
125
+ "text": (
126
+ "승인: POST /permissions/approve/{token} | "
127
+ "거부: POST /permissions/deny/{token} | "
128
+ "목록: GET /permissions/pending"
129
+ )
130
+ },
131
+ }
132
+ ]
133
+ },
134
+ ensure_ascii=False,
135
+ ).encode("utf-8")
136
+ try:
137
+ req = urllib.request.Request(
138
+ self.discord_permission_webhook_url,
139
+ data=payload,
140
+ headers={"Content-Type": "application/json"},
141
+ method="POST",
142
+ )
143
+ with urllib.request.urlopen(req, timeout=5):
144
+ pass
145
+ except Exception as exc:
146
+ logging.warning("Discord permission webhook failed: %s", exc)
147
+
148
+ def local_permission_response(self, path: str, action: str, user_email: str, content: str = "") -> dict:
149
+ normalized = self.normalize_local_path_for_approval(path)
150
+ token = secrets.token_urlsafe(24)
151
+ record: Dict[str, object] = {
152
+ "path": normalized,
153
+ "action": action,
154
+ "user_email": user_email,
155
+ "expires_at": time.time() + self.local_approval_ttl_seconds,
156
+ "approved": False,
157
+ }
158
+ if action == "write":
159
+ record["content_hash"] = self.content_fingerprint(content)
160
+ with self.local_approval_lock:
161
+ self.local_approvals[token] = record
162
+ self._perm_queue_write(token, record)
163
+ action_label = _PERMISSION_ACTION_LABELS.get(action, action)
164
+ return {
165
+ "permission_required": True,
166
+ "path": path,
167
+ "action": action,
168
+ "action_label": action_label,
169
+ "approval_token": token,
170
+ "expires_in": self.local_approval_ttl_seconds,
171
+ "message": f"AI가 '{path}' 에 대한 {action_label} 권한을 요청합니다.",
172
+ "check_status_url": f"/permissions/status/{token}",
173
+ }
174
+
175
+ def require_local_user(self, request: Request) -> str:
176
+ email = self.get_current_user(request)
177
+ if not email:
178
+ raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
179
+ return email
180
+
181
+ def require_local_approval(
182
+ self,
183
+ *,
184
+ token: Optional[str],
185
+ path: str,
186
+ action: str,
187
+ user_email: str,
188
+ content: str = "",
189
+ ) -> None:
190
+ if not token:
191
+ raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
192
+ normalized = self.normalize_local_path_for_approval(path)
193
+ now = time.time()
194
+ with self.local_approval_lock:
195
+ expired = [key for key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
196
+ for key in expired:
197
+ self.local_approvals.pop(key, None)
198
+ record = self.local_approvals.get(token)
199
+ if not record:
200
+ raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
201
+ if not record.get("approved"):
202
+ raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다. Discord 또는 UI에서 승인해주세요.")
203
+ if record.get("user_email") != user_email:
204
+ raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
205
+ if record.get("path") != normalized or record.get("action") != action:
206
+ raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
207
+ if action == "write" and record.get("content_hash") != self.content_fingerprint(content):
208
+ raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
209
+
210
+ def check_permission_auth(self, request: Request, token: Optional[str] = None) -> None:
211
+ if self.permission_monitor_secret:
212
+ auth_header = request.headers.get("Authorization", "")
213
+ if auth_header == f"Bearer {self.permission_monitor_secret}":
214
+ return
215
+ if token:
216
+ current_user = self.get_current_user(request)
217
+ with self.local_approval_lock:
218
+ record = self.local_approvals.get(token)
219
+ if current_user and record and record.get("user_email") == current_user:
220
+ return
221
+ self.require_admin(request)
222
+
223
+
224
+ def create_permissions_router(
225
+ *,
226
+ config,
227
+ data_dir: Path,
228
+ require_user,
229
+ require_admin,
230
+ get_current_user,
231
+ ) -> Tuple[APIRouter, PermissionGateway]:
232
+ router = APIRouter()
233
+ gateway = PermissionGateway(
234
+ config=config,
235
+ data_dir=data_dir,
236
+ require_admin=require_admin,
237
+ get_current_user=get_current_user,
238
+ )
239
+
240
+ @router.get("/permissions/pending")
241
+ async def permissions_pending(request: Request):
242
+ require_admin(request)
243
+ now = time.time()
244
+ with gateway.local_approval_lock:
245
+ result = {}
246
+ for tok, rec in list(gateway.local_approvals.items()):
247
+ expires_at = float(rec.get("expires_at", 0))
248
+ if expires_at < now:
249
+ continue
250
+ result[tok] = {
251
+ "path": rec.get("path"),
252
+ "action": rec.get("action"),
253
+ "action_label": _PERMISSION_ACTION_LABELS.get(str(rec.get("action", "")), str(rec.get("action", ""))),
254
+ "user_email": rec.get("user_email"),
255
+ "approved": bool(rec.get("approved")),
256
+ "expires_in": round(expires_at - now),
257
+ }
258
+ return {"pending": result, "count": len(result)}
259
+
260
+ @router.post("/permissions/approve/{token}")
261
+ async def permissions_approve(token: str, request: Request):
262
+ gateway.check_permission_auth(request, token)
263
+ with gateway.local_approval_lock:
264
+ record = gateway.local_approvals.get(token)
265
+ if not record:
266
+ raise HTTPException(status_code=404, detail="토큰이 없거나 만료되었습니다.")
267
+ if float(record.get("expires_at", 0)) < time.time():
268
+ gateway.local_approvals.pop(token, None)
269
+ raise HTTPException(status_code=410, detail="토큰이 만료되었습니다.")
270
+ record["approved"] = True
271
+ gateway._perm_queue_remove(token)
272
+ logging.info(
273
+ "Permission approved: token=%s path=%s action=%s user=%s",
274
+ token,
275
+ record.get("path"),
276
+ record.get("action"),
277
+ record.get("user_email"),
278
+ )
279
+ return {
280
+ "ok": True,
281
+ "token": token,
282
+ "path": record.get("path"),
283
+ "action": record.get("action"),
284
+ "user_email": record.get("user_email"),
285
+ }
286
+
287
+ @router.post("/permissions/deny/{token}")
288
+ async def permissions_deny(token: str, request: Request):
289
+ gateway.check_permission_auth(request, token)
290
+ with gateway.local_approval_lock:
291
+ record = gateway.local_approvals.pop(token, None)
292
+ gateway._perm_queue_remove(token)
293
+ if not record:
294
+ raise HTTPException(status_code=404, detail="토큰이 없거나 이미 처리되었습니다.")
295
+ logging.info(
296
+ "Permission denied: token=%s path=%s action=%s user=%s",
297
+ token,
298
+ record.get("path"),
299
+ record.get("action"),
300
+ record.get("user_email"),
301
+ )
302
+ return {
303
+ "ok": True,
304
+ "denied": True,
305
+ "token": token,
306
+ "path": record.get("path"),
307
+ "action": record.get("action"),
308
+ }
309
+
310
+ @router.get("/permissions/status/{token}")
311
+ async def permissions_status(token: str, request: Request):
312
+ require_user(request)
313
+ now = time.time()
314
+ with gateway.local_approval_lock:
315
+ record = gateway.local_approvals.get(token)
316
+ if not record:
317
+ return {"status": "denied_or_expired", "token": token}
318
+ if float(record.get("expires_at", 0)) < now:
319
+ return {"status": "expired", "token": token}
320
+ if record.get("approved"):
321
+ return {"status": "approved", "token": token}
322
+ return {
323
+ "status": "pending",
324
+ "token": token,
325
+ "expires_in": round(float(record.get("expires_at", 0)) - now),
326
+ }
327
+
328
+ return router, gateway
329
+
330
+
331
+ __all__ = ["PermissionGateway", "create_permissions_router"]
@@ -0,0 +1,158 @@
1
+ """Setup wizard and OS permission routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from fastapi.responses import StreamingResponse
9
+ from pydantic import BaseModel
10
+
11
+ from auto_setup import (
12
+ plan as auto_setup_plan,
13
+ preset as auto_setup_preset,
14
+ probe as auto_setup_probe,
15
+ recommend as auto_setup_recommend,
16
+ verify as auto_setup_verify,
17
+ )
18
+ from llm_router import parse_model_ref
19
+ from setup import get_recommendations, install_stream, open_url, scan_environment
20
+
21
+
22
+ def create_setup_router(*, model_router, require_user) -> APIRouter:
23
+ api_router = APIRouter()
24
+ router = model_router
25
+
26
+ # ── Setup Wizard ─────────────────────────────────────────────────────────────
27
+
28
+ class SetupInstallRequest(BaseModel):
29
+ items: List[Dict]
30
+
31
+ def setup_auto_state() -> Dict[str, object]:
32
+ """Return the PPT-aligned zero-config setup state used by setup UI/API."""
33
+ profile = auto_setup_probe()
34
+ recommendation = auto_setup_recommend(profile)
35
+ install_plan = auto_setup_plan(profile, recommendation)
36
+ return {
37
+ "probe": profile.to_json(),
38
+ "recommend": recommendation.to_json(),
39
+ "plan": install_plan.to_json(),
40
+ "verify": auto_setup_verify(profile, recommendation),
41
+ "preset": auto_setup_preset(profile, recommendation),
42
+ }
43
+
44
+
45
+ def primary_setup_model(recs: Dict[str, object]) -> Optional[Dict[str, object]]:
46
+ models = recs.get("models") if isinstance(recs, dict) else None
47
+ if not isinstance(models, list):
48
+ return None
49
+ candidates = [
50
+ item for item in models
51
+ if isinstance(item, dict) and not item.get("disabled") and (item.get("model_id") or (item.get("action") or {}).get("model_id"))
52
+ ]
53
+ if not candidates:
54
+ return None
55
+ return next((item for item in candidates if item.get("checked")), candidates[0])
56
+
57
+
58
+ @api_router.get("/setup/scan")
59
+ async def setup_scan(request: Request):
60
+ """환경 감지 및 맞춤 추천 반환."""
61
+ require_user(request)
62
+ env = scan_environment()
63
+ recs = get_recommendations(env)
64
+ zero_config = setup_auto_state()
65
+ primary_model = primary_setup_model(recs)
66
+ if primary_model:
67
+ model_id = primary_model.get("model_id") or (primary_model.get("action") or {}).get("model_id")
68
+ model_provider, provider_model = parse_model_ref(str(model_id))
69
+ primary_runtime = "mlx" if model_provider == "local_mlx" else model_provider
70
+ zero_config.setdefault("recommend", {})["model_id"] = model_id
71
+ zero_config["recommend"]["runtime"] = primary_runtime
72
+ rationale = [
73
+ item for item in zero_config["recommend"].get("rationale", [])
74
+ if not (isinstance(item, str) and item.startswith("RAM ") and "→" in item)
75
+ ]
76
+ rationale.append(f"실제 다운로드 및 로드 가능한 {primary_runtime} 모델 → {model_id}")
77
+ zero_config["recommend"]["rationale"] = rationale
78
+ if isinstance(zero_config.get("plan"), dict):
79
+ if model_provider == "ollama":
80
+ command = ["ollama", "pull", provider_model]
81
+ elif model_provider in {"vllm", "lmstudio", "llamacpp"}:
82
+ command = ["lattice-ai", "models", "load", str(model_id)]
83
+ else:
84
+ command = ["huggingface-cli", "download", str(model_id), "--quiet"]
85
+ zero_config["plan"]["steps"] = [{
86
+ "name": f"weights:{model_id}",
87
+ "why": "추론에 사용할 모델 가중치",
88
+ "command": command,
89
+ "requires_admin": False,
90
+ }]
91
+ if isinstance(zero_config.get("preset"), dict):
92
+ zero_config["preset"].setdefault("model", {})["id"] = model_id
93
+ zero_config["preset"]["model"]["runtime"] = primary_runtime
94
+ env["zero_config"] = zero_config
95
+ recs.setdefault("summary", {})["zero_config"] = zero_config["recommend"]
96
+ recs["install_plan"] = zero_config["plan"]
97
+ recs["preset"] = zero_config["preset"]
98
+ return {"environment": env, "recommendations": recs, "zero_config": zero_config}
99
+
100
+ @api_router.get("/setup/auto")
101
+ async def setup_auto(request: Request):
102
+ """PPT-aligned zero-config setup pipeline: probe → recommend → plan → verify → preset."""
103
+ require_user(request)
104
+ return setup_auto_state()
105
+
106
+ @api_router.post("/setup/install")
107
+ async def setup_install(req: SetupInstallRequest, request: Request):
108
+ """선택된 항목을 순서대로 설치 · 로드하는 SSE 스트림."""
109
+ require_user(request)
110
+ async def _gen():
111
+ async for chunk in install_stream(req.items, router):
112
+ yield chunk
113
+ return StreamingResponse(_gen(), media_type="text/event-stream",
114
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
115
+
116
+ @api_router.post("/setup/open-auth/{mcp_id}")
117
+ async def setup_open_auth(mcp_id: str, request: Request):
118
+ require_user(request)
119
+ """MCP 인증 페이지를 브라우저에서 자동으로 엽니다."""
120
+ auth_urls: Dict[str, str] = {
121
+ "github": "https://github.com/apps",
122
+ "google-drive": "https://chatgpt.com/connectors",
123
+ "slack": "https://chatgpt.com/connectors",
124
+ "chrome": "https://chatgpt.com/connectors",
125
+ "computer-use": "https://chatgpt.com/connectors",
126
+ "figma": "https://chatgpt.com/connectors",
127
+ "notion": "https://chatgpt.com/connectors",
128
+ "linear": "https://chatgpt.com/connectors",
129
+ "gmail": "https://chatgpt.com/connectors",
130
+ "google-calendar": "https://chatgpt.com/connectors",
131
+ "outlook-email": "https://chatgpt.com/connectors",
132
+ "outlook-calendar": "https://chatgpt.com/connectors",
133
+ "teams": "https://chatgpt.com/connectors",
134
+ "sharepoint": "https://chatgpt.com/connectors",
135
+ "canva": "https://chatgpt.com/connectors",
136
+ }
137
+ url = auth_urls.get(mcp_id)
138
+ if not url:
139
+ raise HTTPException(status_code=404, detail=f"알 수 없는 MCP: {mcp_id}")
140
+ open_url(url)
141
+ return {"status": "ok", "opened": url, "mcp_id": mcp_id}
142
+
143
+
144
+ @api_router.post("/permissions/open/{permission_id}")
145
+ async def open_permission_settings(permission_id: str, request: Request):
146
+ require_user(request)
147
+ """macOS 권한 설정 화면을 엽니다."""
148
+ urls = {
149
+ "accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
150
+ "automation": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
151
+ "screen": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
152
+ }
153
+ url = urls.get(permission_id)
154
+ if not url:
155
+ raise HTTPException(status_code=404, detail="알 수 없는 권한 설정입니다.")
156
+ open_url(url)
157
+ return {"status": "ok", "opened": url, "permission": permission_id}
158
+ return api_router
@@ -0,0 +1,166 @@
1
+ """Static UI and lightweight status routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, Optional
10
+
11
+ from fastapi import APIRouter, Cookie, HTTPException, Request
12
+ from fastapi.responses import FileResponse, HTMLResponse
13
+
14
+ def ui_file_response(path: Path) -> FileResponse:
15
+ response = FileResponse(path)
16
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
17
+ response.headers["Pragma"] = "no-cache"
18
+ response.headers["Expires"] = "0"
19
+ return response
20
+
21
+ @dataclass(frozen=True)
22
+ class StaticRoutesBundle:
23
+ router: APIRouter
24
+ ui_file_response: Callable[[Path], FileResponse]
25
+ local_sysinfo: Callable[[Request], object]
26
+
27
+
28
+ def create_static_routes_router(
29
+ *,
30
+ static_dir: Path,
31
+ invite_gate_enabled: bool,
32
+ invite_code: str,
33
+ app_mode: str,
34
+ model_router,
35
+ require_user,
36
+ ) -> StaticRoutesBundle:
37
+ api_router = APIRouter()
38
+ STATIC_DIR = static_dir
39
+ INVITE_GATE_ENABLED = invite_gate_enabled
40
+ INVITE_CODE = invite_code
41
+ APP_MODE = app_mode
42
+ router = model_router
43
+
44
+ @api_router.get("/")
45
+ async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
46
+ """로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
47
+ if not INVITE_GATE_ENABLED:
48
+ return ui_file_response(STATIC_DIR / "account.html")
49
+
50
+ # 1. 이미 쿠키로 인증된 경우
51
+ if authorized == "true":
52
+ return ui_file_response(STATIC_DIR / "account.html")
53
+
54
+ # 2. 초대 코드가 일치하는 경우 (최초 진입)
55
+ if code == INVITE_CODE:
56
+ response = ui_file_response(STATIC_DIR / "account.html")
57
+ response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
58
+ return response
59
+
60
+ # 3. 인증 실패 시 차단 화면
61
+ return HTMLResponse(content=f"""
62
+ <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
63
+ <div style="background:#16191f; padding:40px; border-radius:24px; border:1px solid rgba(255,255,255,0.1); text-align:center; box-shadow: 0 20px 40px rgba(0,0,0,0.5);">
64
+ <div style="font-size:48px; margin-bottom:20px;">🔒</div>
65
+ <h1 style="color:#378ADD; margin:0; font-size:24px;">Invitation Required</h1>
66
+ <p style="color:#94a3b8; margin:20px 0; line-height:1.6;">이 서비스는 비공개로 운영되고 있습니다.<br>선생님께 받은 <b>초대용 전용 링크</b>를 통해 접속해 주세요.</p>
67
+ <div style="margin-top:30px; padding-top:20px; border-top:1px solid rgba(255,255,255,0.05); font-size:11px; color:rgba(255,255,255,0.2); letter-spacing:1px;">LATTICE AI</div>
68
+ </div>
69
+ </body>
70
+ """, status_code=403)
71
+
72
+
73
+ @api_router.get("/account")
74
+ async def account_page():
75
+ """Direct login/register page route used by logout and manual navigation."""
76
+ return ui_file_response(STATIC_DIR / "account.html")
77
+
78
+
79
+ @api_router.get("/manifest.json")
80
+ async def manifest():
81
+ p = STATIC_DIR / "manifest.json"
82
+ if not p.exists():
83
+ raise HTTPException(status_code=404)
84
+ return FileResponse(str(p), media_type="application/manifest+json")
85
+
86
+
87
+ @api_router.get("/sw.js")
88
+ async def service_worker():
89
+ p = STATIC_DIR / "sw.js"
90
+ if not p.exists():
91
+ raise HTTPException(status_code=404)
92
+ resp = FileResponse(str(p), media_type="application/javascript")
93
+ resp.headers["Service-Worker-Allowed"] = "/"
94
+ return resp
95
+
96
+
97
+ @api_router.get("/chat")
98
+ async def chat_page(request: Request):
99
+ return ui_file_response(STATIC_DIR / "chat.html")
100
+
101
+
102
+ @api_router.get("/admin")
103
+ async def admin_page():
104
+ admin_path = STATIC_DIR / "admin.html"
105
+ if not admin_path.exists():
106
+ raise HTTPException(status_code=404, detail="Admin UI not found.")
107
+ response = FileResponse(admin_path)
108
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
109
+ return response
110
+
111
+ # /workspace and /onboarding UI pages are served by the workspace router
112
+ # (latticeai.api.workspace), included below after its dependencies are defined.
113
+
114
+ @api_router.get("/status")
115
+ async def status():
116
+ """서버 상태 및 현재 로드된 모델 정보를 반환합니다."""
117
+ return {
118
+ "message": "🧠 Lattice AI MLX Server is running!",
119
+ "status": "online",
120
+ "mode": APP_MODE,
121
+ "loaded_model": router._current or "None"
122
+ }
123
+
124
+
125
+ @api_router.get("/local/sysinfo")
126
+ async def local_sysinfo(request: Request):
127
+ """CPU / RAM / GPU(MLX) 사용량을 반환합니다."""
128
+ require_user(request)
129
+ import subprocess, re as _re
130
+ result = {"cpu_pct": 0.0, "ram_pct": 0.0, "gpu_mem_pct": 0.0, "gpu_mem_gb": 0.0}
131
+ try:
132
+ # CPU
133
+ top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
134
+ for line in top_out.splitlines():
135
+ if "CPU usage" in line:
136
+ m = _re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
137
+ if m:
138
+ result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
139
+ # RAM
140
+ vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
141
+ page_size = 16384
142
+ pages: dict = {}
143
+ for line in vm_out.splitlines():
144
+ for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
145
+ if line.startswith(key):
146
+ m = _re.search(r"(\d+)", line)
147
+ if m:
148
+ pages[key] = int(m.group(1))
149
+ total = sum(pages.values())
150
+ used = total - pages.get("Pages free", 0)
151
+ result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
152
+ # GPU (MLX / Apple Silicon unified memory)
153
+ try:
154
+ import mlx.core as _mx
155
+ hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
156
+ total_bytes = int(hw_out.strip())
157
+ gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
158
+ result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
159
+ result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
160
+ except Exception:
161
+ pass
162
+ except Exception as e:
163
+ result["error"] = str(e)
164
+ return result
165
+
166
+ return StaticRoutesBundle(api_router, ui_file_response, local_sysinfo)