ltcai 1.3.0 → 1.5.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.
- package/README.md +105 -79
- package/docs/CHANGELOG.md +109 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/tmp_frames/frame_00.png +0 -0
- package/docs/images/tmp_frames/frame_01.png +0 -0
- package/docs/images/tmp_frames/frame_02.png +0 -0
- package/docs/images/tmp_frames/frame_03.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +17 -0
- package/latticeai/api/chat.py +786 -0
- package/latticeai/api/computer_use.py +294 -0
- package/latticeai/api/deps.py +15 -0
- package/latticeai/api/garden.py +34 -0
- package/latticeai/api/local_files.py +125 -0
- package/latticeai/api/models.py +16 -0
- package/latticeai/api/permissions.py +331 -0
- package/latticeai/api/setup.py +158 -0
- package/latticeai/api/static_routes.py +166 -0
- package/latticeai/api/tools.py +579 -0
- package/latticeai/api/workspace.py +11 -0
- package/latticeai/core/enterprise_admin.py +158 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +223 -4301
- package/latticeai/services/app_context.py +27 -0
- package/latticeai/services/model_catalog.py +289 -0
- package/latticeai/services/model_recommendation.py +183 -0
- package/latticeai/services/model_runtime.py +1721 -0
- package/latticeai/services/tool_dispatch.py +135 -0
- package/latticeai/services/upload_service.py +99 -0
- package/package.json +3 -3
- package/skills/SKILL_TEMPLATE.md +1 -1
- package/skills/code_review/SKILL.md +1 -1
- package/skills/data_analysis/SKILL.md +1 -1
- package/skills/file_edit/SKILL.md +1 -1
- package/skills/summarize_document/SKILL.md +1 -1
- package/skills/web_search/SKILL.md +1 -1
- package/static/scripts/chat.js +45 -0
|
@@ -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)
|