ltcai 0.1.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/server.py ADDED
@@ -0,0 +1,3215 @@
1
+ """
2
+ Lattice AI MLX — Local LLM Bridge Server
3
+ Apple Silicon (M1-M5) 전용 | mlx-lm 기반
4
+ """
5
+
6
+ import asyncio
7
+ import base64
8
+ import hashlib
9
+ import importlib.util
10
+ import io
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import secrets
16
+ import threading
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import time
22
+ from contextlib import asynccontextmanager
23
+ from pathlib import Path
24
+
25
+ try:
26
+ import mlx.core as mx
27
+ mx.set_default_device(mx.gpu)
28
+ print("✅ MLX Metal context initialized in main thread.")
29
+ except Exception as e:
30
+ print(f"⚠️ MLX Metal context unavailable: {e}")
31
+ mx = None
32
+ from typing import AsyncIterator, Optional, List, Dict
33
+
34
+ import uvicorn
35
+ from fastapi import FastAPI, File, HTTPException, Request, Cookie, UploadFile
36
+ from fastapi.middleware.cors import CORSMiddleware
37
+ from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
38
+ from fastapi.staticfiles import StaticFiles
39
+ from pydantic import BaseModel
40
+ from PIL import Image
41
+
42
+ from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, parse_model_ref, mx, normalize_branding
43
+ from p_reinforce import BRAIN_DIR, PReinforceGardener
44
+ from setup import get_recommendations, install_stream, open_url, scan_environment
45
+ from telegram_bot import broadcast_web_chat
46
+ from tools import (
47
+ AGENT_ROOT,
48
+ ToolError,
49
+ build_project,
50
+ computer_click,
51
+ computer_drag,
52
+ computer_key,
53
+ computer_move,
54
+ computer_open_app,
55
+ computer_open_url,
56
+ computer_screenshot,
57
+ computer_scroll,
58
+ computer_status,
59
+ computer_type,
60
+ create_docx,
61
+ create_pdf,
62
+ create_pptx,
63
+ create_xlsx,
64
+ read_document,
65
+ deploy_project,
66
+ desktop_bridge_status,
67
+ ensure_agent_root,
68
+ execute_tool,
69
+ git_diff,
70
+ git_log,
71
+ git_show,
72
+ git_status,
73
+ inspect_html,
74
+ knowledge_save,
75
+ knowledge_search,
76
+ knowledge_tree,
77
+ list_dir,
78
+ local_list,
79
+ local_read,
80
+ local_write,
81
+ network_status,
82
+ obsidian_save,
83
+ obsidian_search,
84
+ obsidian_tree,
85
+ preview_url,
86
+ read_file,
87
+ run_command,
88
+ search_files,
89
+ workspace_tree,
90
+ write_file,
91
+ )
92
+
93
+ try:
94
+ import keyring
95
+ except Exception:
96
+ keyring = None
97
+
98
+ from datetime import datetime
99
+
100
+ def detect_language(text: str) -> str:
101
+ """Detect language: 'ko' (Korean), 'zh' (Chinese), or 'en' (English)."""
102
+ total = max(len(text), 1)
103
+ ko = sum(1 for c in text if '가' <= c <= '힣')
104
+ zh = sum(1 for c in text if '一' <= c <= '鿿')
105
+ if ko / total > 0.05:
106
+ return "ko"
107
+ if zh / total > 0.05:
108
+ return "zh"
109
+ return "en"
110
+
111
+ _LANG_HINT = {
112
+ "ko": "Respond in Korean (한국어로 답변하세요).",
113
+ "zh": "Respond in Chinese (用中文回答).",
114
+ "en": "Respond in English.",
115
+ }
116
+
117
+ def is_network_status_request(text: str) -> bool:
118
+ """사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
119
+ t = (text or "").lower()
120
+ has_ip = bool(re.search(r"((?<![a-z0-9])ip(?![a-z0-9])|아이피|ip\s*주소|아이피\s*주소|ipconfig|ifconfig|네트워크)", t))
121
+ asks_current = any(word in t for word in ["내", "현재", "지금", "local", "로컬", "주소", "address", "뭐", "알려", "확인", "상태"])
122
+ return has_ip and asks_current
123
+
124
+ def is_current_url_request(text: str) -> bool:
125
+ t = (text or "").lower()
126
+ has_url = any(word in t for word in ["url", "주소", "링크", "address"])
127
+ asks_current = any(word in t for word in ["현재", "지금", "여기", "접속", "페이지", "브라우저", "알려", "뭐"])
128
+ return has_url and asks_current
129
+
130
+ def is_clear_command(text: str) -> bool:
131
+ return (text or "").strip().lower() in {"/clear", "/clear_all"}
132
+
133
+ def format_network_status(info: Dict) -> str:
134
+ lines = [
135
+ f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
136
+ f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
137
+ f"호스트명: {info.get('hostname') or '확인 안 됨'}",
138
+ ]
139
+ local_ips = info.get("local_ips") or {}
140
+ if local_ips:
141
+ lines.extend(["", "인터페이스:"])
142
+ lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
143
+ note = info.get("note")
144
+ if note:
145
+ lines.extend(["", note])
146
+ return "\n".join(lines)
147
+
148
+ async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
149
+ yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
150
+ yield "data: [DONE]\n\n"
151
+
152
+ def env_value(primary: str, default: Optional[str] = None) -> str:
153
+ return os.getenv(primary) or default or ""
154
+
155
+ def env_bool(name: str, default: bool = False) -> bool:
156
+ raw = os.getenv(name)
157
+ if raw is None:
158
+ return default
159
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
160
+
161
+ APP_MODE = env_value("LATTICEAI_MODE", "local").strip().lower()
162
+ if APP_MODE not in {"local", "public"}:
163
+ APP_MODE = "local"
164
+ IS_PUBLIC_MODE = APP_MODE == "public"
165
+ DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
166
+ DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
167
+ ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
168
+ AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
169
+ MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
170
+ ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
171
+ REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE)
172
+ ALLOW_PLAINTEXT_API_KEYS = env_bool("LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False)
173
+ CORS_ALLOW_NETWORK = env_bool("LATTICEAI_CORS_ALLOW_NETWORK", default=False)
174
+ PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
175
+ LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
176
+ LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
177
+
178
+ # ── Password hashing (stdlib scrypt, no extra deps) ────────────────────────────
179
+ def hash_password(password: str) -> str:
180
+ salt = secrets.token_hex(16)
181
+ key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
182
+ return f"{salt}:{key.hex()}"
183
+
184
+ def verify_password(password: str, hashed: str) -> bool:
185
+ try:
186
+ salt, key_hex = hashed.split(":", 1)
187
+ key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
188
+ return secrets.compare_digest(key.hex(), key_hex)
189
+ except Exception:
190
+ return False
191
+
192
+ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
193
+ """평문 비밀번호를 투명하게 해시로 마이그레이션."""
194
+ if ":" in stored and len(stored) > 64:
195
+ return verify_password(plain, stored)
196
+ if plain == stored:
197
+ users[email]["password"] = hash_password(plain)
198
+ save_users(users)
199
+ return True
200
+ return False
201
+
202
+ # ── Session store (in-memory, clears on restart) ──────────────────────────────
203
+ _SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
204
+ _sessions: Dict[str, tuple] = {} # token → (email, created_at)
205
+
206
+ def create_session(email: str) -> str:
207
+ token = secrets.token_urlsafe(32)
208
+ _sessions[token] = (email, time.time())
209
+ return token
210
+
211
+ 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
220
+
221
+ def invalidate_session(token: str) -> None:
222
+ _sessions.pop(token, None)
223
+
224
+ # ── User Management Logic ──────────────────────────────────────────────────
225
+ BASE_DIR = Path(__file__).resolve().parent
226
+ DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
227
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
228
+ STATIC_DIR = Path(env_value("LATTICEAI_STATIC_DIR", str(BASE_DIR / "static")))
229
+ if not STATIC_DIR.exists():
230
+ packaged_static = Path(sys.prefix) / "static"
231
+ if packaged_static.exists():
232
+ STATIC_DIR = packaged_static
233
+
234
+ USERS_FILE = DATA_DIR / "users.json"
235
+ HISTORY_FILE = DATA_DIR / "chat_history.json"
236
+ VPC_FILE = DATA_DIR / "vpc_config.json"
237
+ MCP_FILE = DATA_DIR / "mcp_installs.json"
238
+
239
+ class UserRegister(BaseModel):
240
+ email: str
241
+ password: str
242
+ name: str
243
+ nickname: str
244
+
245
+ class UserLogin(BaseModel):
246
+ email: str
247
+ password: str
248
+
249
+ class AdminUserUpdate(BaseModel):
250
+ role: Optional[str] = None
251
+ disabled: Optional[bool] = None
252
+
253
+ class VpcConfigUpdate(BaseModel):
254
+ provider: Optional[str] = None
255
+ region: Optional[str] = None
256
+ cidr_block: Optional[str] = None
257
+ private_subnets: Optional[List[str]] = None
258
+ endpoint: Optional[str] = None
259
+ vpn_status: Optional[str] = None
260
+ peering_status: Optional[str] = None
261
+ notes: Optional[str] = None
262
+
263
+ class McpRecommendRequest(BaseModel):
264
+ query: str
265
+ limit: int = 5
266
+
267
+ class McpInstallRequest(BaseModel):
268
+ mcp_id: str
269
+
270
+ DEFAULT_VPC_CONFIG = {
271
+ "provider": "AWS",
272
+ "region": "ap-northeast-2",
273
+ "cidr_block": "10.42.0.0/16",
274
+ "private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
275
+ "endpoint": "ltcai-private.local",
276
+ "vpn_status": "standby",
277
+ "peering_status": "not_configured",
278
+ "notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
279
+ "updated_at": None,
280
+ }
281
+
282
+ MCP_REGISTRY = [
283
+ {
284
+ "id": "presentations",
285
+ "name": "Presentations MCP",
286
+ "category": "PPT / slides",
287
+ "install_mode": "bundled",
288
+ "description": "PowerPoint, Google Slides용 발표자료를 만들고 렌더링 검수까지 이어갑니다.",
289
+ "keywords": ["ppt", "powerpoint", "slides", "slide", "deck", "presentation", "발표", "피피티", "프레젠테이션", "슬라이드", "제안서"],
290
+ "capabilities": ["PPTX 생성", "슬라이드 구조화", "차트 중심 스토리", "렌더링 검수"],
291
+ },
292
+ {
293
+ "id": "documents",
294
+ "name": "Documents MCP",
295
+ "category": "Docs / reports",
296
+ "install_mode": "bundled",
297
+ "description": "Word 문서, 보고서, 계약서 초안, 문서 redline 및 시각 검수를 처리합니다.",
298
+ "keywords": ["docx", "word", "docs", "document", "report", "문서", "보고서", "계약서", "기획서", "레포트"],
299
+ "capabilities": ["DOCX 생성", "문서 편집", "코멘트/수정", "PDF 렌더 확인"],
300
+ },
301
+ {
302
+ "id": "spreadsheets",
303
+ "name": "Spreadsheets MCP",
304
+ "category": "Sheets / data",
305
+ "install_mode": "bundled",
306
+ "description": "Excel/CSV/Google Sheets형 데이터 분석, 수식, 표, 차트를 만듭니다.",
307
+ "keywords": ["xlsx", "excel", "spreadsheet", "sheet", "csv", "data", "엑셀", "스프레드시트", "표", "데이터", "차트"],
308
+ "capabilities": ["XLSX 생성", "수식/서식", "데이터 분석", "차트"],
309
+ },
310
+ {
311
+ "id": "browser",
312
+ "name": "Browser MCP",
313
+ "category": "Web / dashboard QA",
314
+ "install_mode": "bundled",
315
+ "description": "로컬 웹앱, 대시보드, 폼, 페이지 렌더링을 브라우저에서 확인합니다.",
316
+ "keywords": ["dashboard", "web", "website", "frontend", "ui", "browser", "localhost", "대시보드", "웹", "사이트", "프론트", "화면", "검수"],
317
+ "capabilities": ["로컬 페이지 열기", "스크린샷", "DOM 검사", "UI 회귀 확인"],
318
+ },
319
+ {
320
+ "id": "chrome",
321
+ "name": "Chrome MCP",
322
+ "category": "Browser / authenticated web",
323
+ "install_mode": "connector",
324
+ "connector_url": "/mcp/connectors/chrome",
325
+ "external_url": "codex://plugins/chrome",
326
+ "description": "사용자 Chrome 프로필, 로그인 세션, 기존 탭을 활용하는 브라우저 자동화 브리지입니다.",
327
+ "keywords": ["chrome", "browser", "cookie", "session", "login", "크롬", "브라우저", "로그인", "세션", "탭"],
328
+ "capabilities": ["Chrome 탭 확인", "로그인 세션 활용", "프로필 기반 웹 자동화"],
329
+ },
330
+ {
331
+ "id": "computer-use",
332
+ "name": "Computer Use MCP",
333
+ "category": "Desktop / Mac UI",
334
+ "install_mode": "connector",
335
+ "connector_url": "/mcp/connectors/computer-use",
336
+ "external_url": "codex://plugins/computer-use",
337
+ "description": "Mac 앱 화면을 읽고 클릭, 타이핑, 스크롤하는 데스크톱 UI 자동화 브리지입니다.",
338
+ "keywords": ["computer use", "desktop", "mac", "click", "type", "scroll", "컴퓨터", "맥", "앱", "클릭", "타이핑"],
339
+ "capabilities": ["Mac 앱 UI 조작", "스크린샷 기반 상태 확인", "클릭/입력/스크롤"],
340
+ },
341
+ {
342
+ "id": "filesystem",
343
+ "name": "Workspace Files MCP",
344
+ "category": "Files / coding",
345
+ "install_mode": "builtin",
346
+ "description": "프로젝트 파일 읽기/쓰기, 검색, 코드 생성, 로컬 preview URL 생성을 수행합니다.",
347
+ "keywords": ["code", "coding", "file", "folder", "project", "build", "deploy", "구현", "코드", "파일", "폴더", "프로젝트", "빌드", "배포"],
348
+ "capabilities": ["파일 생성", "코드 검색", "빌드 스크립트", "배포 스크립트"],
349
+ },
350
+ {
351
+ "id": "google-drive",
352
+ "name": "Google Drive Connector",
353
+ "category": "File sharing",
354
+ "install_mode": "connector",
355
+ "connector_url": "/mcp/connectors/google-drive",
356
+ "external_url": "https://chatgpt.com/connectors",
357
+ "description": "Drive/Docs/Sheets/Slides 파일 공유, 검색, 협업 워크플로에 사용합니다.",
358
+ "keywords": ["share", "sharing", "drive", "google drive", "file share", "공유", "파일공유", "드라이브", "구글드라이브", "협업"],
359
+ "capabilities": ["파일 공유", "Drive 검색", "Google Docs/Sheets/Slides 연결"],
360
+ },
361
+ {
362
+ "id": "github",
363
+ "name": "GitHub Connector",
364
+ "category": "Code hosting",
365
+ "install_mode": "connector",
366
+ "connector_url": "/mcp/connectors/github",
367
+ "external_url": "https://github.com/apps",
368
+ "description": "저장소, 이슈, PR, CI 확인과 코드 배포 워크플로를 연결합니다.",
369
+ "keywords": ["github", "repo", "repository", "pr", "pull request", "issue", "ci", "깃허브", "저장소", "이슈", "배포"],
370
+ "capabilities": ["PR 확인", "이슈 탐색", "CI 확인", "릴리즈 준비"],
371
+ },
372
+ {
373
+ "id": "slack",
374
+ "name": "Slack Connector",
375
+ "category": "Team sharing",
376
+ "install_mode": "connector",
377
+ "connector_url": "/mcp/connectors/slack",
378
+ "external_url": "https://chatgpt.com/connectors",
379
+ "description": "팀 채널에 결과 공유, 논의 요약, 알림 워크플로를 연결합니다.",
380
+ "keywords": ["slack", "message", "team", "notify", "공유", "알림", "메시지", "슬랙", "팀"],
381
+ "capabilities": ["채널 공유", "메시지 작성", "협업 알림"],
382
+ },
383
+ {
384
+ "id": "obsidian-memory",
385
+ "name": "Obsidian Memory Vault",
386
+ "category": "Memory / knowledge",
387
+ "install_mode": "builtin",
388
+ "description": "Lattice AI의 장기 기억을 Obsidian 호환 Markdown vault에 저장하고 검색합니다.",
389
+ "keywords": ["memory", "remember", "obsidian", "vault", "knowledge", "기억", "메모리", "옵시디언", "지식", "노트"],
390
+ "capabilities": ["Markdown vault 저장", "장기 기억 검색", "Obsidian URI 힌트", "프로젝트 로그"],
391
+ },
392
+ {
393
+ "id": "voice-whisper",
394
+ "name": "Voice STT (Whisper Local)",
395
+ "category": "Voice / speech-to-text",
396
+ "install_mode": "pip",
397
+ "pip_packages": ["openai-whisper"],
398
+ "description": "로컬 음성 인식(STT) 파이프라인용 Whisper 런타임을 설치합니다.",
399
+ "keywords": ["voice", "speech", "stt", "whisper", "audio", "음성", "인식", "자막", "전사"],
400
+ "capabilities": ["로컬 STT 런타임", "오디오 전사 워크플로 준비"],
401
+ },
402
+ {
403
+ "id": "voice-speechrecognition",
404
+ "name": "Voice STT (SpeechRecognition)",
405
+ "category": "Voice / speech-to-text",
406
+ "install_mode": "pip",
407
+ "pip_packages": ["SpeechRecognition"],
408
+ "description": "가벼운 음성 인식 실험용 SpeechRecognition 패키지를 설치합니다.",
409
+ "keywords": ["voice", "speech", "recognition", "stt", "microphone", "음성", "마이크", "받아쓰기"],
410
+ "capabilities": ["STT 파이썬 패키지", "마이크 입력 인식 실험"],
411
+ },
412
+ {
413
+ "id": "audio-pydub",
414
+ "name": "Audio Processing (PyDub)",
415
+ "category": "Voice / audio processing",
416
+ "install_mode": "pip",
417
+ "pip_packages": ["pydub"],
418
+ "description": "오디오 파일 분할/정규화/포맷 변환 워크플로용 패키지를 설치합니다.",
419
+ "keywords": ["audio", "pydub", "wav", "mp3", "전처리", "오디오", "변환"],
420
+ "capabilities": ["오디오 전처리", "세그먼트 분할", "포맷 변환"],
421
+ },
422
+ {
423
+ "id": "threejs-workflow",
424
+ "name": "3D Workflow (Three.js)",
425
+ "category": "3D / interactive web",
426
+ "install_mode": "bundled",
427
+ "description": "브라우저 검수 + 코드 생성 흐름으로 Three.js 기반 3D 화면을 구현/검증합니다.",
428
+ "keywords": ["3d", "three", "threejs", "webgl", "scene", "3차원", "쓰리제이에스", "렌더링"],
429
+ "capabilities": ["Three.js 코드 생성", "3D 씬 검수", "브라우저 상호작용 테스트"],
430
+ },
431
+ {
432
+ "id": "figma",
433
+ "name": "Figma Connector",
434
+ "category": "Design / handoff",
435
+ "install_mode": "connector",
436
+ "connector_url": "/mcp/connectors/figma",
437
+ "external_url": "https://chatgpt.com/connectors",
438
+ "description": "디자인 파일 참조, 컴포넌트 규칙 확인, 구현 핸드오프를 연결합니다.",
439
+ "keywords": ["figma", "design", "handoff", "컴포넌트", "디자인", "피그마"],
440
+ "capabilities": ["디자인 참조", "핸드오프 워크플로", "컴포넌트 맵핑"],
441
+ },
442
+ {
443
+ "id": "notion",
444
+ "name": "Notion Connector",
445
+ "category": "Knowledge / docs",
446
+ "install_mode": "connector",
447
+ "connector_url": "/mcp/connectors/notion",
448
+ "external_url": "https://chatgpt.com/connectors",
449
+ "description": "노션 문서/DB와 연동해 구현 노트, 회의 요약, 지식 관리 워크플로를 만듭니다.",
450
+ "keywords": ["notion", "wiki", "docs", "database", "노션", "위키", "문서", "지식관리"],
451
+ "capabilities": ["페이지 검색", "문서 작성 보조", "지식 동기화"],
452
+ },
453
+ {
454
+ "id": "linear",
455
+ "name": "Linear Connector",
456
+ "category": "Project / issue tracking",
457
+ "install_mode": "connector",
458
+ "connector_url": "/mcp/connectors/linear",
459
+ "external_url": "https://chatgpt.com/connectors",
460
+ "description": "이슈 상태 확인, 우선순위 정리, 릴리즈 태스크 연결에 사용합니다.",
461
+ "keywords": ["linear", "issue", "project", "sprint", "이슈", "태스크", "프로젝트"],
462
+ "capabilities": ["이슈 조회", "작업 우선순위", "릴리즈 트래킹"],
463
+ },
464
+ {
465
+ "id": "gmail",
466
+ "name": "Gmail Connector",
467
+ "category": "Communication / email",
468
+ "install_mode": "connector",
469
+ "connector_url": "/mcp/connectors/gmail",
470
+ "external_url": "https://chatgpt.com/connectors",
471
+ "description": "이메일 요약, 답장 초안, 업무 메일 정리에 사용합니다.",
472
+ "keywords": ["gmail", "email", "mail", "inbox", "메일", "지메일", "이메일"],
473
+ "capabilities": ["메일 검색", "요약", "답장 초안"],
474
+ },
475
+ {
476
+ "id": "google-calendar",
477
+ "name": "Google Calendar Connector",
478
+ "category": "Scheduling / calendar",
479
+ "install_mode": "connector",
480
+ "connector_url": "/mcp/connectors/google-calendar",
481
+ "external_url": "https://chatgpt.com/connectors",
482
+ "description": "일정 확인, 미팅 슬롯 탐색, 일정 생성 워크플로를 연결합니다.",
483
+ "keywords": ["calendar", "schedule", "meeting", "구글캘린더", "일정", "미팅"],
484
+ "capabilities": ["일정 조회", "빈 시간 탐색", "이벤트 생성"],
485
+ },
486
+ {
487
+ "id": "outlook-email",
488
+ "name": "Outlook Email Connector",
489
+ "category": "Communication / email",
490
+ "install_mode": "connector",
491
+ "connector_url": "/mcp/connectors/outlook-email",
492
+ "external_url": "https://chatgpt.com/connectors",
493
+ "description": "Outlook 메일함 연동, 메일 검색/초안/요약 워크플로를 제공합니다.",
494
+ "keywords": ["outlook", "email", "mail", "아웃룩", "메일"],
495
+ "capabilities": ["메일 검색", "요약", "초안 작성"],
496
+ },
497
+ {
498
+ "id": "outlook-calendar",
499
+ "name": "Outlook Calendar Connector",
500
+ "category": "Scheduling / calendar",
501
+ "install_mode": "connector",
502
+ "connector_url": "/mcp/connectors/outlook-calendar",
503
+ "external_url": "https://chatgpt.com/connectors",
504
+ "description": "Outlook 일정 연동으로 회의 준비/시간 조율 작업을 진행합니다.",
505
+ "keywords": ["outlook calendar", "calendar", "schedule", "아웃룩 캘린더", "일정"],
506
+ "capabilities": ["일정 조회", "회의 준비", "시간 조율"],
507
+ },
508
+ {
509
+ "id": "teams",
510
+ "name": "Microsoft Teams Connector",
511
+ "category": "Team collaboration",
512
+ "install_mode": "connector",
513
+ "connector_url": "/mcp/connectors/teams",
514
+ "external_url": "https://chatgpt.com/connectors",
515
+ "description": "팀 대화 컨텍스트 기반 업무 자동화와 협업 공유를 지원합니다.",
516
+ "keywords": ["teams", "microsoft teams", "chat", "협업", "팀즈"],
517
+ "capabilities": ["팀 대화 공유", "협업 흐름 연결"],
518
+ },
519
+ {
520
+ "id": "sharepoint",
521
+ "name": "SharePoint Connector",
522
+ "category": "Enterprise files",
523
+ "install_mode": "connector",
524
+ "connector_url": "/mcp/connectors/sharepoint",
525
+ "external_url": "https://chatgpt.com/connectors",
526
+ "description": "SharePoint 문서 저장소를 검색/참조하는 엔터프라이즈 워크플로를 지원합니다.",
527
+ "keywords": ["sharepoint", "document", "enterprise", "문서", "셰어포인트"],
528
+ "capabilities": ["문서 검색", "사내 파일 참조"],
529
+ },
530
+ {
531
+ "id": "canva",
532
+ "name": "Canva Connector",
533
+ "category": "Design / visuals",
534
+ "install_mode": "connector",
535
+ "connector_url": "/mcp/connectors/canva",
536
+ "external_url": "https://chatgpt.com/connectors",
537
+ "description": "디자인 템플릿 기반 이미지/슬라이드 작업을 연동합니다.",
538
+ "keywords": ["canva", "design", "poster", "card", "캔바", "디자인"],
539
+ "capabilities": ["디자인 템플릿", "이미지 제작 워크플로"],
540
+ },
541
+ ]
542
+
543
+ def load_users():
544
+ if not os.path.exists(USERS_FILE):
545
+ return {}
546
+ with open(USERS_FILE, "r", encoding="utf-8") as f:
547
+ return json.load(f)
548
+
549
+ def save_users(users):
550
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
551
+ json.dump(users, f, ensure_ascii=False, indent=2)
552
+
553
+ def load_vpc_config() -> Dict:
554
+ if not os.path.exists(VPC_FILE):
555
+ return DEFAULT_VPC_CONFIG.copy()
556
+ try:
557
+ with open(VPC_FILE, "r", encoding="utf-8") as f:
558
+ stored = json.load(f)
559
+ return {**DEFAULT_VPC_CONFIG, **stored}
560
+ except Exception:
561
+ return DEFAULT_VPC_CONFIG.copy()
562
+
563
+ def save_vpc_config(config: Dict):
564
+ config["updated_at"] = datetime.now().isoformat()
565
+ with open(VPC_FILE, "w", encoding="utf-8") as f:
566
+ json.dump(config, f, ensure_ascii=False, indent=2)
567
+
568
+ def load_mcp_installs() -> Dict:
569
+ if not os.path.exists(MCP_FILE):
570
+ return {"installed": {}, "updated_at": None}
571
+ try:
572
+ with open(MCP_FILE, "r", encoding="utf-8") as f:
573
+ data = json.load(f)
574
+ if "installed" not in data:
575
+ data["installed"] = {}
576
+ return data
577
+ except Exception:
578
+ return {"installed": {}, "updated_at": None}
579
+
580
+ def save_mcp_installs(data: Dict):
581
+ data["updated_at"] = datetime.now().isoformat()
582
+ with open(MCP_FILE, "w", encoding="utf-8") as f:
583
+ json.dump(data, f, ensure_ascii=False, indent=2)
584
+
585
+ def mcp_public_item(item: Dict, installed_state: Dict) -> Dict:
586
+ state = installed_state.get(item["id"]) or {}
587
+ installed = item["install_mode"] in {"builtin", "bundled"} or bool(state.get("installed"))
588
+ connector_pending = item["install_mode"] == "connector" and not state.get("authenticated")
589
+ authenticated = item["install_mode"] != "connector" or bool(state.get("authenticated"))
590
+ return {
591
+ **{key: item[key] for key in ["id", "name", "category", "install_mode", "description", "capabilities"]},
592
+ "connector_url": item.get("connector_url"),
593
+ "external_url": item.get("external_url"),
594
+ "installed": installed,
595
+ "status": state.get("status") or ("active" if installed and not connector_pending else "needs_auth" if connector_pending else "available"),
596
+ "authenticated": authenticated,
597
+ "updated_at": state.get("updated_at"),
598
+ }
599
+
600
+ def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
601
+ text = (query or "").lower()
602
+ installed = load_mcp_installs().get("installed", {})
603
+ scored = []
604
+ for item in MCP_REGISTRY:
605
+ score = 0
606
+ hits = []
607
+ for keyword in item["keywords"]:
608
+ if keyword.lower() in text:
609
+ score += 3 if len(keyword) > 2 else 1
610
+ hits.append(keyword)
611
+ if item["id"] == "filesystem" and any(word in text for word in ["만들", "구현", "build", "deploy", "코드", "앱"]):
612
+ score += 2
613
+ if score:
614
+ public = mcp_public_item(item, installed)
615
+ public["score"] = score
616
+ public["matched_keywords"] = hits[:6]
617
+ scored.append(public)
618
+ if not scored:
619
+ fallback_ids = ["filesystem", "browser", "documents"]
620
+ scored = [
621
+ {**mcp_public_item(item, installed), "score": 1, "matched_keywords": []}
622
+ for item in MCP_REGISTRY
623
+ if item["id"] in fallback_ids
624
+ ]
625
+ return sorted(scored, key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 24))]
626
+
627
+ def install_mcp(mcp_id: str) -> Dict:
628
+ item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
629
+ if not item:
630
+ raise HTTPException(status_code=404, detail="MCP를 찾을 수 없습니다.")
631
+ data = load_mcp_installs()
632
+ state = data.setdefault("installed", {})
633
+ status = "active"
634
+ message = "MCP가 활성화되었습니다."
635
+ if item["install_mode"] == "connector":
636
+ status = "needs_auth"
637
+ message = "커넥터 인증이 필요합니다. Codex 앱의 connector 설정에서 계정을 연결하면 바로 사용할 수 있습니다."
638
+ elif item["install_mode"] == "pip":
639
+ packages = item.get("pip_packages") or []
640
+ for pkg in packages:
641
+ completed = subprocess.run(
642
+ [sys.executable, "-m", "pip", "install", "--upgrade", pkg],
643
+ capture_output=True,
644
+ text=True,
645
+ timeout=900,
646
+ check=False,
647
+ )
648
+ if completed.returncode != 0:
649
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
650
+ status = "active"
651
+ message = f"필수 패키지 설치 완료: {', '.join(packages)}"
652
+ state[mcp_id] = {
653
+ "installed": True,
654
+ "status": status,
655
+ "authenticated": item["install_mode"] != "connector",
656
+ "updated_at": datetime.now().isoformat(),
657
+ }
658
+ save_mcp_installs(data)
659
+ public = mcp_public_item(item, state)
660
+ public["message"] = message
661
+ return public
662
+
663
+ def connector_info(mcp_id: str) -> Dict:
664
+ item = next((entry for entry in MCP_REGISTRY if entry["id"] == mcp_id), None)
665
+ if not item or item.get("install_mode") != "connector":
666
+ raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
667
+ installed = load_mcp_installs().get("installed", {})
668
+ public = mcp_public_item(item, installed)
669
+ public["instructions"] = [
670
+ "Codex 또는 ChatGPT 앱의 Connectors 설정을 엽니다.",
671
+ f"{item['name']} 항목을 선택하고 계정을 인증합니다.",
672
+ "인증 후 Lattice AI에서 이 MCP를 다시 활성화하면 작업에 사용할 수 있습니다.",
673
+ ]
674
+ return public
675
+
676
+ _history_lock = threading.Lock()
677
+
678
+ def save_to_history(
679
+ role: str,
680
+ message: str,
681
+ user_email: Optional[str] = None,
682
+ user_nickname: Optional[str] = None,
683
+ source: Optional[str] = None,
684
+ conversation_id: Optional[str] = None,
685
+ ):
686
+ try:
687
+ message = redact_secret_text(message)
688
+ if role == "assistant":
689
+ message = normalize_branding(message)
690
+ item = {"role": role, "content": message, "timestamp": datetime.now().isoformat()}
691
+ if user_email:
692
+ item["user_email"] = user_email
693
+ if user_nickname:
694
+ item["user_nickname"] = user_nickname
695
+ if source:
696
+ item["source"] = source
697
+ if conversation_id:
698
+ item["conversation_id"] = conversation_id
699
+ with _history_lock:
700
+ history = []
701
+ if os.path.exists(HISTORY_FILE):
702
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
703
+ history = json.load(f)
704
+ history.append(item)
705
+ if len(history) > 50:
706
+ history = history[-50:]
707
+ tmp_path = str(HISTORY_FILE) + ".tmp"
708
+ with open(tmp_path, "w", encoding="utf-8") as f:
709
+ json.dump(history, f, ensure_ascii=False, indent=2)
710
+ os.replace(tmp_path, HISTORY_FILE)
711
+ except Exception as e:
712
+ logging.warning("save_to_history failed: %s", e)
713
+
714
+ def redact_secret_text(text: str) -> str:
715
+ if not text:
716
+ return ""
717
+ patterns = [
718
+ r"(?i)(api[_ -]?key|secret|token|password|passwd)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{12,})['\"]?",
719
+ r"\b(sk-[A-Za-z0-9_\-]{16,})\b",
720
+ r"\b(xai-[A-Za-z0-9_\-]{16,})\b",
721
+ r"\b(gsk_[A-Za-z0-9_\-]{16,})\b",
722
+ ]
723
+ redacted = str(text)
724
+ for pattern in patterns:
725
+ redacted = re.sub(pattern, lambda m: f"{m.group(1)}=[REDACTED]" if len(m.groups()) > 1 else "[REDACTED]", redacted)
726
+ return redacted
727
+
728
+ def get_history():
729
+ if not os.path.exists(HISTORY_FILE):
730
+ return []
731
+ try:
732
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
733
+ return json.load(f)
734
+ except Exception as e:
735
+ logging.warning("get_history failed: %s", e)
736
+ return []
737
+
738
+ def conversation_title(item: Dict) -> str:
739
+ content = str(item.get("content") or "").strip()
740
+ content = re.sub(r"\s+", " ", content)
741
+ return content[:48] or "새 대화"
742
+
743
+ def group_history_conversations(history: Optional[List[Dict]] = None) -> List[Dict]:
744
+ history = history if history is not None else get_history()
745
+ conversations: Dict[str, Dict] = {}
746
+ order: List[str] = []
747
+
748
+ for index, item in enumerate(history):
749
+ conv_id = item.get("conversation_id")
750
+ if not conv_id:
751
+ conv_id = "legacy-previous-history"
752
+
753
+ if conv_id not in conversations:
754
+ conversations[conv_id] = {
755
+ "id": conv_id,
756
+ "title": "이전 대화 기록" if conv_id == "legacy-previous-history" else conversation_title(item),
757
+ "created_at": item.get("timestamp"),
758
+ "updated_at": item.get("timestamp"),
759
+ "message_count": 0,
760
+ "last_message": "",
761
+ "source": item.get("source"),
762
+ }
763
+ order.append(conv_id)
764
+
765
+ conv = conversations[conv_id]
766
+ conv["message_count"] += 1
767
+ conv["updated_at"] = item.get("timestamp") or conv.get("updated_at")
768
+ conv["last_message"] = conversation_title(item)
769
+ if conv_id != "legacy-previous-history" and item.get("role") == "user" and (not conv.get("title") or conv["title"] == "새 대화"):
770
+ conv["title"] = conversation_title(item)
771
+
772
+ return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
773
+
774
+ def get_conversation_messages(conversation_id: str) -> List[Dict]:
775
+ history = get_history()
776
+ if conversation_id == "legacy-previous-history":
777
+ return [item for item in history if not item.get("conversation_id")]
778
+ return [item for item in history if item.get("conversation_id") == conversation_id]
779
+
780
+ def clear_history(keep_last: int = 0) -> Dict:
781
+ keep_last = max(0, min(int(keep_last or 0), 20))
782
+ previous = get_history()
783
+ kept = previous[-keep_last:] if keep_last else []
784
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
785
+ json.dump(kept, f, ensure_ascii=False, indent=2)
786
+ return {"status": "cleared", "removed": max(0, len(previous) - len(kept)), "kept": len(kept)}
787
+
788
+ def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
789
+ previous = get_history()
790
+ kept = []
791
+ removed = 0
792
+ for item in previous:
793
+ item_conversation_id = item.get("conversation_id")
794
+ should_remove = item_conversation_id == conversation_id
795
+ if conversation_id == "legacy-previous-history":
796
+ should_remove = not item_conversation_id
797
+ elif started_at and not item_conversation_id:
798
+ should_remove = str(item.get("timestamp") or "") >= started_at
799
+
800
+ if should_remove:
801
+ removed += 1
802
+ else:
803
+ kept.append(item)
804
+
805
+ with open(HISTORY_FILE, "w", encoding="utf-8") as f:
806
+ json.dump(kept, f, ensure_ascii=False, indent=2)
807
+ return {"status": "cleared", "conversation_id": conversation_id, "removed": removed, "kept": len(kept)}
808
+
809
+ def build_recent_chat_context(
810
+ limit: int = 10,
811
+ include_image_missing_replies: bool = True,
812
+ user_email: Optional[str] = None,
813
+ conversation_id: Optional[str] = None,
814
+ ) -> str:
815
+ history = get_history()
816
+ if conversation_id:
817
+ history = [item for item in history if item.get("conversation_id") == conversation_id]
818
+ if user_email:
819
+ history = [item for item in history if item.get("user_email") == user_email or item.get("role") == "assistant"]
820
+ history = history[-limit:]
821
+ lines = []
822
+ for item in history:
823
+ role = item.get("role", "user")
824
+ content = item.get("content", "")
825
+ if not include_image_missing_replies and role == "assistant":
826
+ if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
827
+ continue
828
+ source = item.get("source")
829
+ label = role
830
+ if source:
831
+ label = f"{role} ({source})"
832
+ lines.append(f"{label}: {content}")
833
+ return "\n".join(lines)
834
+
835
+ def extract_screenshot_context(image_data: Optional[str]) -> str:
836
+ if not image_data:
837
+ return ""
838
+
839
+ lines = ["[SCREENSHOT INGESTION]"]
840
+ image_bytes = b""
841
+ try:
842
+ image_bytes = base64.b64decode(image_data)
843
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
844
+ lines.append(f"- image_size: {image.width}x{image.height}")
845
+ lines.append(f"- image_mode: {image.mode}")
846
+ except Exception as e:
847
+ lines.append(f"- image_decode_error: {e}")
848
+ return "\n".join(lines)
849
+
850
+ tesseract_path = shutil.which("tesseract")
851
+ if not tesseract_path:
852
+ lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
853
+ return "\n".join(lines)
854
+
855
+ temp_path = None
856
+ try:
857
+ with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
858
+ temp.write(image_bytes)
859
+ temp_path = temp.name
860
+
861
+ ocr_text = ""
862
+ for lang in ("kor+eng", "eng"):
863
+ completed = subprocess.run(
864
+ [tesseract_path, temp_path, "stdout", "-l", lang, "--psm", "6"],
865
+ capture_output=True,
866
+ text=True,
867
+ timeout=20,
868
+ check=False,
869
+ )
870
+ if completed.returncode == 0 and completed.stdout.strip():
871
+ ocr_text = completed.stdout.strip()
872
+ lines.append(f"- ocr_language: {lang}")
873
+ break
874
+
875
+ if ocr_text:
876
+ lines.append("- ocr_text:")
877
+ lines.append(ocr_text[:4000])
878
+ else:
879
+ lines.append("- ocr: no text extracted.")
880
+ except Exception as e:
881
+ lines.append(f"- ocr_error: {e}")
882
+ finally:
883
+ if temp_path:
884
+ try:
885
+ Path(temp_path).unlink()
886
+ except OSError:
887
+ pass
888
+
889
+ return "\n".join(lines)
890
+
891
+ def get_user_role(email: str, users: Optional[Dict] = None) -> str:
892
+ users = users or load_users()
893
+ user = users.get(email) or {}
894
+ if user.get("role") in {"admin", "user"}:
895
+ return user["role"]
896
+ admin_emails = {
897
+ item.strip().lower()
898
+ for item in env_value("LATTICEAI_ADMIN_EMAILS", "").split(",")
899
+ if item.strip()
900
+ }
901
+ if email.lower() in admin_emails:
902
+ return "admin"
903
+ first_email = next(iter(users), None)
904
+ return "admin" if first_email == email else "user"
905
+
906
+ def _extract_bearer_token(request: Request) -> Optional[str]:
907
+ auth = request.headers.get("Authorization", "")
908
+ if auth.startswith("Bearer "):
909
+ return auth[7:].strip()
910
+ return request.cookies.get("session_token")
911
+
912
+ def get_current_user(request: Request) -> Optional[str]:
913
+ token = _extract_bearer_token(request)
914
+ if token:
915
+ return get_session_email(token)
916
+ return None
917
+
918
+ def require_user(request: Request) -> str:
919
+ email = get_current_user(request)
920
+ if REQUIRE_AUTH and not email:
921
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
922
+ return email or ""
923
+
924
+ def require_admin(request: Request) -> tuple[str, Dict]:
925
+ token = _extract_bearer_token(request)
926
+ if token:
927
+ email = get_session_email(token)
928
+ if email:
929
+ users = load_users()
930
+ if get_user_role(email, users) == "admin":
931
+ return email, users
932
+ raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
933
+
934
+ def public_user(email: str, user: Dict, users: Dict) -> Dict:
935
+ return {
936
+ "email": email,
937
+ "name": user.get("name", ""),
938
+ "nickname": user.get("nickname", ""),
939
+ "role": get_user_role(email, users),
940
+ "disabled": bool(user.get("disabled", False)),
941
+ }
942
+
943
+ def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
944
+ if not email:
945
+ return {"user_email": None, "user_nickname": nickname or None}
946
+ users = load_users()
947
+ user = users.get(email, {})
948
+ return {
949
+ "user_email": email,
950
+ "user_nickname": nickname or user.get("nickname") or user.get("name") or email,
951
+ }
952
+
953
+ def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
954
+ if not email:
955
+ return None
956
+ keyring_key = f"{email}:{provider}"
957
+ if keyring is not None:
958
+ try:
959
+ key = keyring.get_password("LatticeAI", keyring_key)
960
+ if key:
961
+ return key.strip()
962
+ except Exception as exc:
963
+ logging.warning("keyring read failed for %s: %s", provider, exc)
964
+ users = load_users()
965
+ user = users.get(email) or {}
966
+ api_keys = user.get("api_keys") or {}
967
+ key = api_keys.get(provider)
968
+ if isinstance(key, str) and key.strip() and ALLOW_PLAINTEXT_API_KEYS:
969
+ return key.strip()
970
+ return None
971
+
972
+ def set_user_api_key(email: str, provider: str, key: str) -> None:
973
+ keyring_key = f"{email}:{provider}"
974
+ if keyring is not None:
975
+ try:
976
+ keyring.set_password("LatticeAI", keyring_key, key)
977
+ users = load_users()
978
+ user = users.get(email)
979
+ if user and "api_keys" in user:
980
+ user["api_keys"].pop(provider, None)
981
+ if not user["api_keys"]:
982
+ user.pop("api_keys", None)
983
+ save_users(users)
984
+ return
985
+ except Exception as exc:
986
+ logging.warning("keyring write failed for %s: %s", provider, exc)
987
+ if not ALLOW_PLAINTEXT_API_KEYS:
988
+ raise HTTPException(
989
+ status_code=500,
990
+ detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
991
+ )
992
+
993
+ if not ALLOW_PLAINTEXT_API_KEYS:
994
+ raise HTTPException(
995
+ status_code=500,
996
+ detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
997
+ )
998
+
999
+ users = load_users()
1000
+ user = users.get(email)
1001
+ if not user:
1002
+ user = {
1003
+ "password_hash": "",
1004
+ "salt": "",
1005
+ "name": email,
1006
+ "nickname": email,
1007
+ "role": "user",
1008
+ "disabled": False,
1009
+ }
1010
+ api_keys = user.get("api_keys") or {}
1011
+ api_keys[provider] = key
1012
+ user["api_keys"] = api_keys
1013
+ users[email] = user
1014
+ save_users(users)
1015
+
1016
+ SENSITIVE_PATTERNS = [
1017
+ {"key": "rrn", "label": "주민등록번호", "severity": "high", "pattern": r"\b\d{6}[- ]?[1-4]\d{6}\b"},
1018
+ {"key": "card", "label": "카드번호", "severity": "high", "pattern": r"\b(?:\d[ -]?){13,19}\b"},
1019
+ {"key": "account", "label": "계좌번호", "severity": "medium", "pattern": r"(?:계좌|account|bank).{0,12}\d[\d -]{8,24}"},
1020
+ {"key": "password", "label": "비밀번호/인증정보", "severity": "high", "pattern": r"(?:password|passwd|비밀번호|암호|token|api[_ -]?key|secret)\s*[:=]\s*[^\s,;]{4,}"},
1021
+ {"key": "email", "label": "이메일", "severity": "low", "pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"},
1022
+ {"key": "phone", "label": "전화번호", "severity": "medium", "pattern": r"\b(?:01[016789]|02|0[3-6][1-5])[- ]?\d{3,4}[- ]?\d{4}\b"},
1023
+ {"key": "address", "label": "주소", "severity": "medium", "pattern": r"(?:[가-힣]+(?:시|도)\s*)?[가-힣]+(?:시|군|구)\s+[가-힣0-9\s-]+(?:로|길)\s*\d*"},
1024
+ {"key": "health", "label": "건강/의료정보", "severity": "medium", "pattern": r"(?:진단|병명|처방|복용|수술|장애|임신|혈액형|알레르기|medical|diagnosis)"},
1025
+ ]
1026
+
1027
+ SEVERITY_SCORE = {"low": 1, "medium": 2, "high": 3}
1028
+
1029
+ def mask_sensitive_text(text: str, matches: List[Dict]) -> str:
1030
+ masked = text
1031
+ for item in sorted(matches, key=lambda match: match["start"], reverse=True):
1032
+ value = masked[item["start"]:item["end"]]
1033
+ if len(value) <= 4:
1034
+ replacement = "*" * len(value)
1035
+ else:
1036
+ replacement = value[:2] + "*" * min(len(value) - 4, 12) + value[-2:]
1037
+ masked = masked[:item["start"]] + replacement + masked[item["end"]:]
1038
+ return masked
1039
+
1040
+ def classify_sensitive_message(item: Dict, index: int) -> Dict:
1041
+ content = str(item.get("content", ""))
1042
+ found = []
1043
+ seen = set()
1044
+ for rule in SENSITIVE_PATTERNS:
1045
+ for match in re.finditer(rule["pattern"], content, flags=re.IGNORECASE):
1046
+ key = (rule["key"], match.start(), match.end())
1047
+ if key in seen:
1048
+ continue
1049
+ seen.add(key)
1050
+ found.append({
1051
+ "type": rule["key"],
1052
+ "label": rule["label"],
1053
+ "severity": rule["severity"],
1054
+ "start": match.start(),
1055
+ "end": match.end(),
1056
+ })
1057
+ severity = "none"
1058
+ if found:
1059
+ severity = max(found, key=lambda item: SEVERITY_SCORE[item["severity"]])["severity"]
1060
+ preview_text = content[:240]
1061
+ preview_matches = [match for match in found if match["start"] < len(preview_text)]
1062
+ return {
1063
+ "index": index,
1064
+ "role": item.get("role", ""),
1065
+ "user_email": item.get("user_email"),
1066
+ "user_nickname": item.get("user_nickname") or item.get("user_email") or "Unknown",
1067
+ "timestamp": item.get("timestamp"),
1068
+ "sensitivity": severity,
1069
+ "labels": sorted({match["label"] for match in found}),
1070
+ "risk_fields": found,
1071
+ "compliance_fields": [] if found else ["민감정보 미검출"],
1072
+ "preview": mask_sensitive_text(preview_text, preview_matches),
1073
+ }
1074
+
1075
+ def build_sensitivity_report(history: List[Dict]) -> Dict:
1076
+ items = [classify_sensitive_message(item, index) for index, item in enumerate(history)]
1077
+ risky_items = [item for item in items if item["risk_fields"]]
1078
+ compliant_items = [item for item in items if not item["risk_fields"]]
1079
+ field_counts = {}
1080
+ user_counts = {}
1081
+ severity_counts = {"high": 0, "medium": 0, "low": 0, "none": len(compliant_items)}
1082
+ for item in risky_items:
1083
+ severity_counts[item["sensitivity"]] += 1
1084
+ user_key = item.get("user_email") or item.get("user_nickname") or "Unknown"
1085
+ user_counts[user_key] = user_counts.get(user_key, 0) + 1
1086
+ for field in item["risk_fields"]:
1087
+ field_counts[field["label"]] = field_counts.get(field["label"], 0) + 1
1088
+ return {
1089
+ "summary": {
1090
+ "total_messages": len(items),
1091
+ "risky_messages": len(risky_items),
1092
+ "compliant_messages": len(compliant_items),
1093
+ "risk_rate": round((len(risky_items) / len(items)) * 100, 1) if items else 0,
1094
+ "severity_counts": severity_counts,
1095
+ "field_counts": field_counts,
1096
+ "user_counts": user_counts,
1097
+ },
1098
+ "risk_fields": risky_items[-30:],
1099
+ "compliance_fields": compliant_items[-30:],
1100
+ }
1101
+
1102
+ router = LLMRouter()
1103
+ gardener = PReinforceGardener()
1104
+
1105
+ async def autoload_default_model() -> None:
1106
+ if not AUTOLOAD_MODELS:
1107
+ print("⏭️ Model autoload disabled by LATTICEAI_AUTOLOAD_MODELS=false.")
1108
+ return
1109
+
1110
+ if IS_PUBLIC_MODE:
1111
+ model_id = PUBLIC_MODEL
1112
+ provider = model_id.split(":", 1)[0] if ":" in model_id else "openai"
1113
+ env_by_provider = {
1114
+ "openai": "OPENAI_API_KEY",
1115
+ "openrouter": "OPENROUTER_API_KEY",
1116
+ "groq": "GROQ_API_KEY",
1117
+ "together": "TOGETHER_API_KEY",
1118
+ "ollama": "OLLAMA_API_KEY",
1119
+ }
1120
+ required_env = env_by_provider.get(provider)
1121
+ if required_env and not os.getenv(required_env) and provider != "ollama":
1122
+ print(f"🌐 Public mode ready. Set {required_env} to autoload {model_id}.")
1123
+ return
1124
+ print(f"🌐 Public mode autoload: {model_id}")
1125
+ try:
1126
+ msg = await router.load_model(model_id)
1127
+ print(f"✅ {msg}")
1128
+ except Exception as e:
1129
+ print(f"⚠️ Public model autoload failed: {e}")
1130
+ return
1131
+
1132
+ if not ALLOW_LOCAL_MODELS:
1133
+ print("⏭️ Local model autoload skipped because LATTICEAI_ALLOW_LOCAL_MODELS=false.")
1134
+ return
1135
+
1136
+ print("⏳ Auto-loading local model stack:")
1137
+ print(f" - Target: {LOCAL_MODEL}")
1138
+ if LOCAL_DRAFT_MODEL:
1139
+ print(f" - Draft: {LOCAL_DRAFT_MODEL}")
1140
+ else:
1141
+ print(" - Draft: disabled (set LATTICEAI_LOCAL_DRAFT_MODEL to enable)")
1142
+ try:
1143
+ await router.load_model(LOCAL_MODEL, draft_model_id=LOCAL_DRAFT_MODEL or None)
1144
+ except Exception as e:
1145
+ print(f"⚠️ Local model autoload failed: {e}")
1146
+
1147
+ async def unload_idle_models_loop() -> None:
1148
+ if MODEL_IDLE_UNLOAD_SECONDS <= 0:
1149
+ print("⏭️ Model idle unload disabled.")
1150
+ return
1151
+ while True:
1152
+ await asyncio.sleep(min(60, MODEL_IDLE_UNLOAD_SECONDS))
1153
+ try:
1154
+ unloaded = router.unload_idle_models(MODEL_IDLE_UNLOAD_SECONDS)
1155
+ if unloaded:
1156
+ print(f"🧹 Idle model unload: {', '.join(unloaded)}")
1157
+ except Exception as e:
1158
+ logging.warning("Idle model unload failed: %s", e)
1159
+
1160
+ @asynccontextmanager
1161
+ async def lifespan(app: FastAPI):
1162
+ try:
1163
+ print(f"🧭 Lattice AI mode: {APP_MODE}")
1164
+ if ENABLE_TELEGRAM:
1165
+ from telegram_bot import run_bot
1166
+ asyncio.create_task(run_bot())
1167
+ print("🚀 Telegram Bot Bridge activated!")
1168
+ else:
1169
+ print("⏭️ Telegram Bot Bridge disabled for this mode.")
1170
+ asyncio.create_task(unload_idle_models_loop())
1171
+ asyncio.create_task(autoload_default_model())
1172
+ except Exception as e:
1173
+ print(f"⚠️ Startup sequence failed: {e}")
1174
+ try:
1175
+ yield
1176
+ finally:
1177
+ router.unload_all()
1178
+
1179
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="2.1.0", lifespan=lifespan)
1180
+
1181
+ CORS_ALLOWED_ORIGINS = ["http://localhost:4825", "http://127.0.0.1:4825"]
1182
+ if CORS_ALLOW_NETWORK:
1183
+ CORS_ALLOWED_ORIGINS = ["*"]
1184
+
1185
+ app.add_middleware(
1186
+ CORSMiddleware,
1187
+ allow_origins=CORS_ALLOWED_ORIGINS,
1188
+ allow_methods=["*"],
1189
+ allow_headers=["*"],
1190
+ )
1191
+
1192
+ # UI 파일이 담길 static 폴더 연결
1193
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
1194
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
1195
+ ensure_agent_root()
1196
+ app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
1197
+
1198
+ @app.post("/register")
1199
+ async def register(req: UserRegister):
1200
+ users = load_users()
1201
+ if req.email in users:
1202
+ raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
1203
+ users[req.email] = {
1204
+ "password": hash_password(req.password),
1205
+ "name": req.name,
1206
+ "nickname": req.nickname,
1207
+ "role": "user",
1208
+ "disabled": False,
1209
+ }
1210
+ save_users(users)
1211
+ return {"status": "ok", "message": "회원가입 성공!"}
1212
+
1213
+ @app.post("/login")
1214
+ async def login(req: UserLogin):
1215
+ users = load_users()
1216
+ user = users.get(req.email)
1217
+ if not user or not verify_and_migrate_password(req.email, req.password, user.get("password", ""), users):
1218
+ raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
1219
+ if user.get("disabled"):
1220
+ raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
1221
+ role = get_user_role(req.email, users)
1222
+ token = create_session(req.email)
1223
+ response = JSONResponse(content={
1224
+ "status": "ok",
1225
+ "nickname": user["nickname"],
1226
+ "name": user["name"],
1227
+ "email": req.email,
1228
+ "role": role,
1229
+ "is_admin": role == "admin",
1230
+ "token": token,
1231
+ })
1232
+ response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 7)
1233
+ return response
1234
+
1235
+ @app.post("/logout")
1236
+ async def logout(request: Request):
1237
+ token = _extract_bearer_token(request)
1238
+ if token:
1239
+ invalidate_session(token)
1240
+ response = JSONResponse(content={"status": "ok"})
1241
+ response.delete_cookie("session_token")
1242
+ return response
1243
+
1244
+ @app.get("/admin/summary")
1245
+ async def admin_summary(request: Request):
1246
+ _, users = require_admin(request)
1247
+ history = get_history()
1248
+ user_messages = [item for item in history if item.get("role") == "user"]
1249
+ assistant_messages = [item for item in history if item.get("role") == "assistant"]
1250
+ last_timestamp = history[-1].get("timestamp") if history else None
1251
+ return {
1252
+ "total_users": len(users),
1253
+ "active_users": sum(1 for user in users.values() if not user.get("disabled")),
1254
+ "admin_users": sum(1 for email in users if get_user_role(email, users) == "admin"),
1255
+ "total_messages": len(history),
1256
+ "user_messages": len(user_messages),
1257
+ "assistant_messages": len(assistant_messages),
1258
+ "last_message_at": last_timestamp,
1259
+ }
1260
+
1261
+ @app.get("/admin/users")
1262
+ async def admin_users(request: Request):
1263
+ _, users = require_admin(request)
1264
+ return [public_user(email, user, users) for email, user in users.items()]
1265
+
1266
+ @app.get("/admin/sensitivity")
1267
+ async def admin_sensitivity(request: Request):
1268
+ require_admin(request)
1269
+ return build_sensitivity_report(get_history())
1270
+
1271
+ @app.get("/vpc/status")
1272
+ async def vpc_status(request: Request):
1273
+ require_user(request)
1274
+ return load_vpc_config()
1275
+
1276
+ @app.patch("/admin/vpc")
1277
+ async def admin_update_vpc(req: VpcConfigUpdate, request: Request):
1278
+ require_admin(request)
1279
+ config = load_vpc_config()
1280
+ update = req.dict(exclude_unset=True)
1281
+ if "private_subnets" in update and update["private_subnets"] is not None:
1282
+ update["private_subnets"] = [item.strip() for item in update["private_subnets"] if item.strip()]
1283
+ config.update(update)
1284
+ save_vpc_config(config)
1285
+ return config
1286
+
1287
+ @app.patch("/admin/users/{email:path}")
1288
+ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
1289
+ admin_email, users = require_admin(request)
1290
+ if email not in users:
1291
+ raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1292
+ if req.role is not None:
1293
+ if req.role not in {"admin", "user"}:
1294
+ raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
1295
+ users[email]["role"] = req.role
1296
+ if req.disabled is not None:
1297
+ if email == admin_email and req.disabled:
1298
+ raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
1299
+ users[email]["disabled"] = req.disabled
1300
+ save_users(users)
1301
+ return public_user(email, users[email], users)
1302
+
1303
+ @app.delete("/admin/users/{email:path}")
1304
+ async def admin_delete_user(email: str, request: Request):
1305
+ admin_email, users = require_admin(request)
1306
+ if email == admin_email:
1307
+ raise HTTPException(status_code=400, detail="자기 자신은 삭제할 수 없습니다.")
1308
+ if email not in users:
1309
+ raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1310
+ deleted = public_user(email, users[email], users)
1311
+ del users[email]
1312
+ save_users(users)
1313
+ return {"status": "ok", "deleted": deleted}
1314
+
1315
+ # ── Invitation Logic ────────────────────────────────────────────────────────
1316
+ INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1317
+ INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
1318
+
1319
+ @app.get("/")
1320
+ async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
1321
+ """기본은 즉시 진입. 필요 시 초대 링크 게이트를 env로 활성화할 수 있습니다."""
1322
+ if not INVITE_GATE_ENABLED:
1323
+ return FileResponse(STATIC_DIR / "indexd.html")
1324
+
1325
+ # 1. 이미 쿠키로 인증된 경우
1326
+ if authorized == "true":
1327
+ return FileResponse(STATIC_DIR / "indexd.html")
1328
+
1329
+ # 2. 초대 코드가 일치하는 경우 (최초 진입)
1330
+ if code == INVITE_CODE:
1331
+ response = FileResponse(STATIC_DIR / "indexd.html")
1332
+ response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
1333
+ return response
1334
+
1335
+ # 3. 인증 실패 시 차단 화면
1336
+ return HTMLResponse(content=f"""
1337
+ <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
1338
+ <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);">
1339
+ <div style="font-size:48px; margin-bottom:20px;">🔒</div>
1340
+ <h1 style="color:#378ADD; margin:0; font-size:24px;">Invitation Required</h1>
1341
+ <p style="color:#94a3b8; margin:20px 0; line-height:1.6;">이 서비스는 비공개로 운영되고 있습니다.<br>선생님께 받은 <b>초대용 전용 링크</b>를 통해 접속해 주세요.</p>
1342
+ <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 SECURITY AGENT</div>
1343
+ </div>
1344
+ </body>
1345
+ """, status_code=403)
1346
+
1347
+
1348
+ @app.get("/admin")
1349
+ async def admin_page():
1350
+ admin_path = STATIC_DIR / "admin.html"
1351
+ if not admin_path.exists():
1352
+ raise HTTPException(status_code=404, detail="Admin UI not found.")
1353
+ response = FileResponse(admin_path)
1354
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
1355
+ return response
1356
+
1357
+ @app.get("/status")
1358
+ async def status():
1359
+ """서버 상태 및 현재 로드된 모델 정보를 반환합니다."""
1360
+ return {
1361
+ "message": "🧠 Lattice AI MLX Server is running!",
1362
+ "status": "online",
1363
+ "mode": APP_MODE,
1364
+ "loaded_model": router._current or "None"
1365
+ }
1366
+
1367
+
1368
+
1369
+
1370
+ # ── Request / Response Models ──────────────────────────────────────────────────
1371
+
1372
+ class ChatRequest(BaseModel):
1373
+ message: str
1374
+ conversation_id: Optional[str] = None
1375
+ client_url: Optional[str] = None
1376
+ model: Optional[str] = None
1377
+ max_tokens: int = 2048
1378
+ temperature: float = 0.2
1379
+ stream: bool = True
1380
+ context: Optional[str] = None
1381
+ source: Optional[str] = None
1382
+ user_email: Optional[str] = None
1383
+ user_nickname: Optional[str] = None
1384
+ image_data: Optional[str] = None # Base64 이미지 데이터 (VLM용)
1385
+
1386
+
1387
+ class LoadModelRequest(BaseModel):
1388
+ model_id: str # HuggingFace repo id 또는 로컬 경로
1389
+ adapter_path: Optional[str] = None # LoRA adapter (선택)
1390
+ draft_model_id: Optional[str] = None # Speculative decoding draft model (선택)
1391
+ engine: Optional[str] = None
1392
+ user_email: Optional[str] = None
1393
+
1394
+
1395
+ class InstallEngineRequest(BaseModel):
1396
+ engine: str
1397
+
1398
+
1399
+ class SetApiKeyRequest(BaseModel):
1400
+ provider: str
1401
+ key: str
1402
+ user_email: Optional[str] = None
1403
+
1404
+
1405
+ class PullModelRequest(BaseModel):
1406
+ model: str
1407
+
1408
+ class VerifyCloudRequest(BaseModel):
1409
+ force: bool = False
1410
+ provider: Optional[str] = None
1411
+
1412
+
1413
+ class GardenRequest(BaseModel):
1414
+ raw_data: str
1415
+ category: Optional[str] = None # 10_Wiki / 00_Raw / Skills
1416
+
1417
+
1418
+ class AgentRequest(BaseModel):
1419
+ message: str
1420
+ conversation_id: Optional[str] = None
1421
+ source: Optional[str] = None
1422
+ max_steps: int = 6
1423
+ temperature: float = 0.1
1424
+ user_email: Optional[str] = None
1425
+ user_nickname: Optional[str] = None
1426
+
1427
+
1428
+ class ToolPathRequest(BaseModel):
1429
+ path: str = "."
1430
+
1431
+
1432
+ class ToolWriteFileRequest(BaseModel):
1433
+ path: str
1434
+ content: str
1435
+
1436
+
1437
+ class ToolRunCommandRequest(BaseModel):
1438
+ command: str
1439
+ cwd: Optional[str] = "."
1440
+
1441
+
1442
+ class ToolScriptRequest(BaseModel):
1443
+ cwd: Optional[str] = "."
1444
+ script: str = "build"
1445
+
1446
+
1447
+ class ToolSearchFilesRequest(BaseModel):
1448
+ query: str
1449
+ path: str = "."
1450
+ max_results: int = 20
1451
+
1452
+
1453
+ class ToolWorkspaceTreeRequest(BaseModel):
1454
+ path: str = "."
1455
+ max_depth: int = 3
1456
+
1457
+
1458
+ class ToolClearHistoryRequest(BaseModel):
1459
+ keep_last: int = 0
1460
+
1461
+
1462
+ class ToolKnowledgeSaveRequest(BaseModel):
1463
+ content: str
1464
+ folder: str = "00_Raw"
1465
+ title: Optional[str] = None
1466
+
1467
+
1468
+ class ToolKnowledgeSearchRequest(BaseModel):
1469
+ query: str
1470
+ max_results: int = 5
1471
+
1472
+
1473
+ class ToolDocxRequest(BaseModel):
1474
+ title: str = ""
1475
+ body: str = ""
1476
+ filename: str = "document.docx"
1477
+
1478
+
1479
+ class ToolXlsxRequest(BaseModel):
1480
+ rows: List[List] = []
1481
+ filename: str = "spreadsheet.xlsx"
1482
+ sheet_name: str = "Sheet1"
1483
+
1484
+
1485
+ class ToolPptxRequest(BaseModel):
1486
+ title: str = ""
1487
+ slides: List[Dict] = []
1488
+ filename: str = "presentation.pptx"
1489
+
1490
+
1491
+ class ToolPdfRequest(BaseModel):
1492
+ title: str = ""
1493
+ body: str = ""
1494
+ filename: str = "document.pdf"
1495
+
1496
+
1497
+ class LocalAccessRequest(BaseModel):
1498
+ path: str
1499
+ approved: bool = False
1500
+
1501
+
1502
+ class LocalWriteRequest(BaseModel):
1503
+ path: str
1504
+ content: str
1505
+ approved: bool = False
1506
+
1507
+
1508
+ class McpCallRequest(BaseModel):
1509
+ action: str
1510
+ args: Dict = {}
1511
+
1512
+
1513
+ class ToolGitDiffRequest(BaseModel):
1514
+ path: Optional[str] = None
1515
+ cwd: Optional[str] = "."
1516
+
1517
+
1518
+ class ToolGitLogRequest(BaseModel):
1519
+ max_count: int = 5
1520
+ cwd: Optional[str] = "."
1521
+
1522
+
1523
+ class ToolGitShowRequest(BaseModel):
1524
+ revision: str = "HEAD"
1525
+ cwd: Optional[str] = "."
1526
+
1527
+
1528
+ # ── Health & Info ──────────────────────────────────────────────────────────────
1529
+
1530
+ ENGINE_INSTALLERS = {
1531
+ "local_mlx": {
1532
+ "command": [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
1533
+ "label": "Install MLX runtime",
1534
+ },
1535
+ "openai": {
1536
+ "command": [sys.executable, "-m", "pip", "install", "openai"],
1537
+ "label": "Install OpenAI-compatible SDK",
1538
+ },
1539
+ "openrouter": {
1540
+ "command": [sys.executable, "-m", "pip", "install", "openai"],
1541
+ "label": "Install OpenAI-compatible SDK",
1542
+ },
1543
+ "groq": {
1544
+ "command": [sys.executable, "-m", "pip", "install", "openai"],
1545
+ "label": "Install OpenAI-compatible SDK",
1546
+ },
1547
+ "together": {
1548
+ "command": [sys.executable, "-m", "pip", "install", "openai"],
1549
+ "label": "Install OpenAI-compatible SDK",
1550
+ },
1551
+ "xai": {
1552
+ "command": [sys.executable, "-m", "pip", "install", "openai"],
1553
+ "label": "Install OpenAI-compatible SDK",
1554
+ },
1555
+ "ollama": {
1556
+ "command": ["brew", "install", "ollama"],
1557
+ "label": "Install Ollama",
1558
+ "requires_binary": "brew",
1559
+ },
1560
+ "vllm": {
1561
+ "command": [sys.executable, "-m", "pip", "install", "vllm", "huggingface_hub[cli]"],
1562
+ "label": "Install vLLM runtime",
1563
+ },
1564
+ "lmstudio": {
1565
+ "command": ["brew", "install", "--cask", "lm-studio"],
1566
+ "label": "Install LM Studio",
1567
+ "requires_binary": "brew",
1568
+ },
1569
+ "llamacpp": {
1570
+ "command": ["brew", "install", "llama.cpp"],
1571
+ "label": "Install llama.cpp",
1572
+ "requires_binary": "brew",
1573
+ },
1574
+ }
1575
+
1576
+ ENGINE_MODEL_CATALOG = {
1577
+ "local_mlx": [
1578
+ {"id": "mlx-community/gemma-4-e2b-4bit", "name": "Gemma 4 E2B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "3.6GB", "pullable": True},
1579
+ {"id": "mlx-community/gemma-4-e2b-it-4bit", "name": "Gemma 4 E2B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "3.6GB", "pullable": True},
1580
+ {"id": "mlx-community/gemma-4-e4b-4bit", "name": "Gemma 4 E4B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
1581
+ {"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
1582
+ {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
1583
+ {"id": "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit", "name": "Qwen 2.5 Coder 3B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "2.1GB", "pullable": True},
1584
+ {"id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", "name": "Qwen 2.5 Coder 7B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "4.3GB", "pullable": True},
1585
+ {"id": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", "name": "Qwen 2.5 Coder 14B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "8.5GB", "pullable": True},
1586
+ {"id": "mlx-community/Qwen2.5-3B-Instruct-4bit", "name": "Qwen 2.5 3B", "family": "Qwen 2.5", "tag": "local-general", "size": "2.1GB", "pullable": True},
1587
+ {"id": "mlx-community/Qwen2.5-7B-Instruct-4bit", "name": "Qwen 2.5 7B", "family": "Qwen 2.5", "tag": "local-general", "size": "4.3GB", "pullable": True},
1588
+ {"id": "mlx-community/Qwen2.5-14B-Instruct-4bit", "name": "Qwen 2.5 14B", "family": "Qwen 2.5", "tag": "local-general", "size": "8.5GB", "pullable": True},
1589
+ {"id": "mlx-community/Llama-3.2-3B-Instruct-4bit", "name": "Llama 3.2 3B", "family": "Llama 3.x", "tag": "local-general", "size": "2.0GB", "pullable": True},
1590
+ {"id": "mlx-community/Llama-3.1-8B-Instruct-4bit", "name": "Llama 3.1 8B", "family": "Llama 3.1", "tag": "local-general", "size": "4.7GB", "pullable": True},
1591
+ {"id": "mlx-community/Llama-3.3-70B-Instruct-4bit", "name": "Llama 3.3 70B", "family": "Llama 3.x", "tag": "local-general", "size": "40GB+", "pullable": True},
1592
+ {"id": "mlx-community/Llama-3.1-70B-Instruct-4bit", "name": "Llama 3.1 70B", "family": "Llama 3.1", "tag": "local-general", "size": "40GB+", "pullable": True},
1593
+ {"id": "mlx-community/Phi-3.5-mini-instruct-4bit", "name": "Phi 3.5 Mini", "family": "Phi", "tag": "local-light", "size": "2.2GB", "pullable": True},
1594
+ {"id": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit", "name": "DeepSeek R1 Distill 7B", "family": "DeepSeek", "tag": "reasoning", "size": "4.3GB", "pullable": True},
1595
+ ],
1596
+ "ollama": [
1597
+ {"id": "ollama:gemma3:4b", "name": "Gemma 3 4B via Ollama", "family": "Gemma", "tag": "local-server", "size": "pull required", "pullable": True},
1598
+ {"id": "ollama:gemma3:4b-it-q4_K_M", "name": "Gemma 3 4B q4_K_M via Ollama", "family": "Gemma", "tag": "quantized", "size": "pull required", "pullable": True},
1599
+ {"id": "ollama:gemma3:12b", "name": "Gemma 3 12B via Ollama", "family": "Gemma", "tag": "local-server", "size": "pull required", "pullable": True},
1600
+ {"id": "ollama:gemma3:12b-it-q4_K_M", "name": "Gemma 3 12B q4_K_M via Ollama", "family": "Gemma", "tag": "quantized", "size": "pull required", "pullable": True},
1601
+ {"id": "ollama:qwen2.5:3b", "name": "Qwen 2.5 3B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
1602
+ {"id": "ollama:qwen2.5:7b", "name": "Qwen 2.5 7B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
1603
+ {"id": "ollama:qwen2.5:14b", "name": "Qwen 2.5 14B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
1604
+ {"id": "ollama:qwen2.5:32b", "name": "Qwen 2.5 32B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
1605
+ {"id": "ollama:qwen2.5-coder:7b", "name": "Qwen 2.5 Coder 7B via Ollama", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "pull required", "pullable": True},
1606
+ {"id": "ollama:qwen2.5-coder:14b", "name": "Qwen 2.5 Coder 14B via Ollama", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "pull required", "pullable": True},
1607
+ {"id": "ollama:llama3.2:3b", "name": "Llama 3.2 3B via Ollama", "family": "Llama 3.x", "tag": "local-server", "size": "pull required", "pullable": True},
1608
+ {"id": "ollama:llama3.1:8b", "name": "Llama 3.1 8B via Ollama", "family": "Llama 3.1", "tag": "local-server", "size": "pull required", "pullable": True},
1609
+ {"id": "ollama:llama3.1:8b-instruct-q4_0", "name": "Llama 3.1 8B q4_0 via Ollama", "family": "Llama 3.1", "tag": "quantized", "size": "pull required", "pullable": True},
1610
+ {"id": "ollama:llama3.1:8b-instruct-q8_0", "name": "Llama 3.1 8B q8_0 via Ollama", "family": "Llama 3.1", "tag": "quantized", "size": "pull required", "pullable": True},
1611
+ {"id": "ollama:llama3.1:70b", "name": "Llama 3.1 70B via Ollama", "family": "Llama 3.1", "tag": "local-server", "size": "pull required", "pullable": True},
1612
+ ],
1613
+ "vllm": [
1614
+ {"id": "vllm:google/gemma-2-2b", "name": "Gemma 2 2B Base via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
1615
+ {"id": "vllm:google/gemma-2-2b-it", "name": "Gemma 2 2B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
1616
+ {"id": "vllm:google/gemma-2-9b", "name": "Gemma 2 9B Base via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
1617
+ {"id": "vllm:google/gemma-2-9b-it", "name": "Gemma 2 9B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
1618
+ {"id": "vllm:Qwen/Qwen2.5-3B-Instruct", "name": "Qwen 2.5 3B via vLLM", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1619
+ {"id": "vllm:Qwen/Qwen2.5-7B-Instruct", "name": "Qwen 2.5 7B via vLLM", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1620
+ {"id": "vllm:Qwen/Qwen2.5-14B-Instruct", "name": "Qwen 2.5 14B via vLLM", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1621
+ {"id": "vllm:Qwen/Qwen2.5-32B-Instruct", "name": "Qwen 2.5 32B via vLLM", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1622
+ {"id": "vllm:Qwen/Qwen2.5-Coder-7B-Instruct", "name": "Qwen 2.5 Coder 7B via vLLM", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "server model", "pullable": True},
1623
+ {"id": "vllm:Qwen/Qwen2.5-Coder-14B-Instruct", "name": "Qwen 2.5 Coder 14B via vLLM", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "server model", "pullable": True},
1624
+ {"id": "vllm:meta-llama/Llama-3.2-3B-Instruct", "name": "Llama 3.2 3B via vLLM", "family": "Llama 3.x", "tag": "local-server", "size": "server model", "pullable": True},
1625
+ {"id": "vllm:meta-llama/Llama-3.1-8B-Instruct", "name": "Llama 3.1 8B via vLLM", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
1626
+ {"id": "vllm:meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B via vLLM", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
1627
+ ],
1628
+ "lmstudio": [
1629
+ {"id": "lmstudio:google/gemma-2-2b-it", "name": "Gemma 2 2B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
1630
+ {"id": "lmstudio:google/gemma-2-9b-it", "name": "Gemma 2 9B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
1631
+ {"id": "lmstudio:Qwen/Qwen2.5-3B-Instruct", "name": "Qwen 2.5 3B via LM Studio", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1632
+ {"id": "lmstudio:Qwen/Qwen2.5-7B-Instruct", "name": "Qwen 2.5 7B via LM Studio", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1633
+ {"id": "lmstudio:Qwen/Qwen2.5-14B-Instruct", "name": "Qwen 2.5 14B via LM Studio", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1634
+ {"id": "lmstudio:Qwen/Qwen2.5-32B-Instruct", "name": "Qwen 2.5 32B via LM Studio", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
1635
+ {"id": "lmstudio:Qwen/Qwen2.5-Coder-7B-Instruct", "name": "Qwen 2.5 Coder 7B via LM Studio", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "server model", "pullable": True},
1636
+ {"id": "lmstudio:Qwen/Qwen2.5-Coder-14B-Instruct", "name": "Qwen 2.5 Coder 14B via LM Studio", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "server model", "pullable": True},
1637
+ {"id": "lmstudio:meta-llama/Llama-3.2-3B-Instruct", "name": "Llama 3.2 3B via LM Studio", "family": "Llama 3.x", "tag": "local-server", "size": "server model", "pullable": True},
1638
+ {"id": "lmstudio:meta-llama/Llama-3.1-8B-Instruct", "name": "Llama 3.1 8B via LM Studio", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
1639
+ {"id": "lmstudio:meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B via LM Studio", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
1640
+ ],
1641
+ "llamacpp": [
1642
+ {"id": "llamacpp:unsloth/gemma-2-2b-it-GGUF", "name": "Gemma 2 2B GGUF via llama.cpp", "family": "Gemma", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1643
+ {"id": "llamacpp:unsloth/gemma-2-9b-it-GGUF", "name": "Gemma 2 9B GGUF via llama.cpp", "family": "Gemma", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1644
+ {"id": "llamacpp:Qwen/Qwen2.5-3B-Instruct-GGUF", "name": "Qwen 2.5 3B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1645
+ {"id": "llamacpp:Qwen/Qwen2.5-7B-Instruct-GGUF", "name": "Qwen 2.5 7B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "local-server", "size": "gguf", "pullable": True},
1646
+ {"id": "llamacpp:Qwen/Qwen2.5-14B-Instruct-GGUF", "name": "Qwen 2.5 14B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "local-server", "size": "gguf", "pullable": True},
1647
+ {"id": "llamacpp:Qwen/Qwen2.5-32B-Instruct-GGUF", "name": "Qwen 2.5 32B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1648
+ {"id": "llamacpp:Qwen/Qwen2.5-Coder-7B-Instruct-GGUF", "name": "Qwen 2.5 Coder 7B GGUF via llama.cpp", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "gguf", "pullable": True},
1649
+ {"id": "llamacpp:Qwen/Qwen2.5-Coder-14B-Instruct-GGUF", "name": "Qwen 2.5 Coder 14B GGUF via llama.cpp", "family": "Qwen 2.5 Coder", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1650
+ {"id": "llamacpp:bartowski/Llama-3.2-3B-Instruct-GGUF", "name": "Llama 3.2 3B GGUF via llama.cpp", "family": "Llama 3.x", "tag": "gguf-q4", "size": "gguf", "pullable": True},
1651
+ {"id": "llamacpp:bartowski/Llama-3.1-8B-Instruct-GGUF", "name": "Llama 3.1 8B GGUF via llama.cpp", "family": "Llama 3.1", "tag": "local-server", "size": "gguf", "pullable": True},
1652
+ {"id": "llamacpp:bartowski/Llama-3.1-70B-Instruct-GGUF", "name": "Llama 3.1 70B GGUF via llama.cpp", "family": "Llama 3.1", "tag": "local-server", "size": "gguf", "pullable": True},
1653
+ ],
1654
+ }
1655
+
1656
+ def _update_env_file(env_file: Path, key: str, value: str) -> None:
1657
+ lines = []
1658
+ found = False
1659
+ if env_file.exists():
1660
+ for line in env_file.read_text(encoding="utf-8").splitlines():
1661
+ if line.startswith(f"{key}="):
1662
+ lines.append(f"{key}={value}")
1663
+ found = True
1664
+ else:
1665
+ lines.append(line)
1666
+ if not found:
1667
+ lines.append(f"{key}={value}")
1668
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
1669
+
1670
+
1671
+ def get_ollama_pulled_models() -> set:
1672
+ if not shutil.which("ollama"):
1673
+ return set()
1674
+ try:
1675
+ result = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=5, check=False)
1676
+ pulled = set()
1677
+ for line in result.stdout.splitlines()[1:]:
1678
+ parts = line.split()
1679
+ if parts:
1680
+ pulled.add(parts[0])
1681
+ return pulled
1682
+ except Exception:
1683
+ return set()
1684
+
1685
+
1686
+ def engine_installed(engine: str) -> bool:
1687
+ if engine == "local_mlx":
1688
+ return bool(mx is not None)
1689
+ if engine == "ollama":
1690
+ return shutil.which("ollama") is not None
1691
+ if engine == "vllm":
1692
+ return importlib.util.find_spec("vllm") is not None
1693
+ if engine == "lmstudio":
1694
+ return shutil.which("lms") is not None or Path("/Applications/LM Studio.app").exists()
1695
+ if engine == "llamacpp":
1696
+ return shutil.which("llama-server") is not None
1697
+ if engine in {"openai", "openrouter", "groq", "together", "xai"}:
1698
+ return AsyncOpenAI is not None
1699
+ return False
1700
+
1701
+ def engine_status() -> List[Dict]:
1702
+ cloud_models = router.detected_cloud_models()
1703
+ cloud_by_provider = {}
1704
+ for model in cloud_models:
1705
+ cloud_by_provider.setdefault(model["provider"], []).append(model)
1706
+
1707
+ ollama_installed = engine_installed("ollama")
1708
+ pulled = get_ollama_pulled_models() if ollama_installed else set()
1709
+ ollama_models = []
1710
+ for m in ENGINE_MODEL_CATALOG["ollama"]:
1711
+ pull_name = m["id"].removeprefix("ollama:")
1712
+ ollama_models.append({**m, "pulled": pull_name in pulled})
1713
+
1714
+ hf_models_root = Path.home() / ".latticeai" / "hf-models"
1715
+ hf_models_root.mkdir(parents=True, exist_ok=True)
1716
+ mlx_models = []
1717
+ for m in ENGINE_MODEL_CATALOG.get("local_mlx", []):
1718
+ repo_id = m["id"]
1719
+ marker = hf_models_root / repo_id.replace("/", "__")
1720
+ mlx_models.append({**m, "pulled": marker.exists()})
1721
+
1722
+ vllm_models = []
1723
+ for m in ENGINE_MODEL_CATALOG.get("vllm", []):
1724
+ repo_id = m["id"].removeprefix("vllm:")
1725
+ marker = hf_models_root / repo_id.replace("/", "__")
1726
+ vllm_models.append({**m, "pulled": marker.exists()})
1727
+
1728
+ lmstudio_models = []
1729
+ for m in ENGINE_MODEL_CATALOG.get("lmstudio", []):
1730
+ repo_id = m["id"].removeprefix("lmstudio:")
1731
+ marker = hf_models_root / repo_id.replace("/", "__")
1732
+ lmstudio_models.append({**m, "pulled": marker.exists()})
1733
+
1734
+ llamacpp_models = []
1735
+ for m in ENGINE_MODEL_CATALOG.get("llamacpp", []):
1736
+ repo_id = m["id"].removeprefix("llamacpp:")
1737
+ marker = hf_models_root / repo_id.replace("/", "__")
1738
+ llamacpp_models.append({**m, "pulled": marker.exists()})
1739
+
1740
+ local_server_specs = [
1741
+ {
1742
+ "id": "vllm",
1743
+ "name": "vLLM",
1744
+ "description": "vLLM OpenAI 호환 서버(예: http://localhost:8000/v1)에 연결합니다.",
1745
+ "requires": "VLLM_BASE_URL",
1746
+ },
1747
+ {
1748
+ "id": "lmstudio",
1749
+ "name": "LM Studio",
1750
+ "description": "LM Studio 로컬 OpenAI 호환 서버에 연결합니다.",
1751
+ "requires": "LMSTUDIO_BASE_URL",
1752
+ },
1753
+ {
1754
+ "id": "llamacpp",
1755
+ "name": "llama.cpp",
1756
+ "description": "llama.cpp 서버(OpenAI 호환 /v1)에 연결합니다.",
1757
+ "requires": "LLAMACPP_BASE_URL",
1758
+ },
1759
+ ]
1760
+
1761
+ engines = [
1762
+ {
1763
+ "id": "local_mlx",
1764
+ "name": "MLX",
1765
+ "kind": "local",
1766
+ "description": "Apple Silicon GPU에서 MLX/MLX-VLM 모델을 직접 실행합니다.",
1767
+ "installed": engine_installed("local_mlx"),
1768
+ "installable": True,
1769
+ "install_label": ENGINE_INSTALLERS["local_mlx"]["label"],
1770
+ "models": mlx_models,
1771
+ },
1772
+ {
1773
+ "id": "ollama",
1774
+ "name": "Ollama",
1775
+ "kind": "local-server",
1776
+ "description": "Ollama 로컬 서버를 OpenAI 호환 엔진처럼 사용합니다.",
1777
+ "installed": ollama_installed,
1778
+ "installable": True,
1779
+ "install_label": ENGINE_INSTALLERS["ollama"]["label"],
1780
+ "models": ollama_models,
1781
+ },
1782
+ ]
1783
+ for spec in local_server_specs:
1784
+ engines.append({
1785
+ "id": spec["id"],
1786
+ "name": spec["name"],
1787
+ "kind": "local-server",
1788
+ "description": spec["description"],
1789
+ "installed": engine_installed(spec["id"]),
1790
+ "installable": spec["id"] in ENGINE_INSTALLERS,
1791
+ "install_label": ENGINE_INSTALLERS.get(spec["id"], {}).get("label"),
1792
+ "requires": spec["requires"],
1793
+ "models": (
1794
+ vllm_models if spec["id"] == "vllm"
1795
+ else lmstudio_models if spec["id"] == "lmstudio"
1796
+ else llamacpp_models if spec["id"] == "llamacpp"
1797
+ else ENGINE_MODEL_CATALOG.get(spec["id"], [])
1798
+ ),
1799
+ "note": f"{spec['requires']} 설정 시 활성화됩니다.",
1800
+ })
1801
+ for provider in ["openai", "openrouter", "groq", "together", "xai"]:
1802
+ env_key = next((item.get("requires") for item in cloud_by_provider.get(provider, []) if item.get("requires")), None)
1803
+ provider_models = []
1804
+ for model in cloud_by_provider.get(provider, []):
1805
+ cache = CLOUD_VERIFY_CACHE.get(model.get("id"))
1806
+ provider_models.append({
1807
+ **model,
1808
+ "verified": cache.get("ok") if cache else None,
1809
+ "verify_reason": cache.get("reason") if cache else None,
1810
+ })
1811
+ engines.append({
1812
+ "id": provider,
1813
+ "name": provider.title(),
1814
+ "kind": "cloud",
1815
+ "description": "OpenAI 호환 Chat Completions API로 cloud LLM을 실행합니다.",
1816
+ "installed": engine_installed(provider),
1817
+ "installable": True,
1818
+ "install_label": ENGINE_INSTALLERS[provider]["label"],
1819
+ "requires": env_key,
1820
+ "models": provider_models,
1821
+ })
1822
+ return engines
1823
+
1824
+ def runtime_features() -> Dict:
1825
+ return {
1826
+ "mode": APP_MODE,
1827
+ "public": IS_PUBLIC_MODE,
1828
+ "host": DEFAULT_HOST,
1829
+ "port": DEFAULT_PORT,
1830
+ "data_dir": str(DATA_DIR),
1831
+ "telegram_enabled": ENABLE_TELEGRAM,
1832
+ "autoload_models": AUTOLOAD_MODELS,
1833
+ "model_idle_unload_seconds": MODEL_IDLE_UNLOAD_SECONDS,
1834
+ "model_memory_policy": router.model_memory_policy(),
1835
+ "allow_local_models": ALLOW_LOCAL_MODELS,
1836
+ "security": {
1837
+ "host": DEFAULT_HOST,
1838
+ "require_auth": REQUIRE_AUTH,
1839
+ "invite_gate_enabled": INVITE_GATE_ENABLED,
1840
+ "keyring_available": keyring is not None,
1841
+ "plaintext_api_keys_allowed": ALLOW_PLAINTEXT_API_KEYS,
1842
+ "cors_allow_network": CORS_ALLOW_NETWORK,
1843
+ },
1844
+ "default_model": PUBLIC_MODEL if IS_PUBLIC_MODE else LOCAL_MODEL,
1845
+ "local_only_features": {
1846
+ "mlx": ALLOW_LOCAL_MODELS and not IS_PUBLIC_MODE,
1847
+ "telegram_bridge": ENABLE_TELEGRAM,
1848
+ "desktop_chrome_bridge": not IS_PUBLIC_MODE,
1849
+ "computer_use_bridge": not IS_PUBLIC_MODE,
1850
+ },
1851
+ "public_features": {
1852
+ "web_ui": True,
1853
+ "openai_compatible_models": True,
1854
+ "persistent_data_dir": str(DATA_DIR),
1855
+ },
1856
+ }
1857
+
1858
+ def install_engine(engine: str) -> Dict:
1859
+ if engine not in ENGINE_INSTALLERS:
1860
+ raise HTTPException(status_code=400, detail="지원하지 않는 엔진입니다.")
1861
+ installer = ENGINE_INSTALLERS[engine]
1862
+ required_binary = installer.get("requires_binary")
1863
+ if required_binary and shutil.which(required_binary) is None:
1864
+ raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
1865
+ try:
1866
+ completed = subprocess.run(
1867
+ installer["command"],
1868
+ cwd=str(Path(__file__).resolve().parent),
1869
+ capture_output=True,
1870
+ text=True,
1871
+ timeout=900,
1872
+ check=False,
1873
+ )
1874
+ except subprocess.TimeoutExpired:
1875
+ raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
1876
+ result = {
1877
+ "engine": engine,
1878
+ "command": " ".join(installer["command"]),
1879
+ "returncode": completed.returncode,
1880
+ "stdout": completed.stdout[-12000:],
1881
+ "stderr": completed.stderr[-12000:],
1882
+ "installed": engine_installed(engine),
1883
+ }
1884
+ if engine == "ollama" and completed.returncode == 0 and shutil.which("ollama"):
1885
+ try:
1886
+ subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
1887
+ result["daemon_started"] = True
1888
+ except Exception:
1889
+ result["daemon_started"] = False
1890
+ return result
1891
+
1892
+ CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}
1893
+ CLOUD_VERIFY_TTL_SECONDS = 600
1894
+
1895
+ async def _probe_cloud_model(model_ref: str) -> Dict[str, object]:
1896
+ provider, model_name = parse_model_ref(model_ref)
1897
+ config = OPENAI_COMPATIBLE_PROVIDERS.get(provider)
1898
+ if not config:
1899
+ return {"ok": False, "reason": f"Unsupported provider: {provider}"}
1900
+
1901
+ api_key = os.getenv(config["env_key"]) or config.get("api_key_fallback")
1902
+ if not api_key:
1903
+ return {"ok": False, "reason": f"Missing API key: {config['env_key']}"}
1904
+
1905
+ base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
1906
+ base_url = base_url or config.get("base_url")
1907
+ client_kwargs = {"api_key": api_key}
1908
+ if base_url:
1909
+ client_kwargs["base_url"] = base_url
1910
+
1911
+ try:
1912
+ client = AsyncOpenAI(**client_kwargs)
1913
+ await asyncio.wait_for(
1914
+ client.chat.completions.create(
1915
+ model=model_name,
1916
+ messages=[{"role": "user", "content": "ping"}],
1917
+ max_tokens=1,
1918
+ temperature=0,
1919
+ ),
1920
+ timeout=15,
1921
+ )
1922
+ return {"ok": True, "reason": "ok"}
1923
+ except Exception as e:
1924
+ return {"ok": False, "reason": str(e)[:220]}
1925
+
1926
+
1927
+ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str] = None) -> Dict[str, Dict]:
1928
+ now = time.time()
1929
+ cloud_items = [item for item in router.detected_cloud_models() if item.get("tag") == "cloud"]
1930
+ if provider_filter:
1931
+ cloud_items = [item for item in cloud_items if item.get("provider") == provider_filter]
1932
+
1933
+ results: Dict[str, Dict] = {}
1934
+ for item in cloud_items:
1935
+ model_ref = item["id"]
1936
+ cached = CLOUD_VERIFY_CACHE.get(model_ref)
1937
+ if not force and cached and (now - cached.get("ts", 0) <= CLOUD_VERIFY_TTL_SECONDS):
1938
+ results[model_ref] = cached
1939
+ continue
1940
+ if item.get("available") is False:
1941
+ record = {"ok": False, "reason": item.get("requires") or "API key missing", "ts": now}
1942
+ CLOUD_VERIFY_CACHE[model_ref] = record
1943
+ results[model_ref] = record
1944
+ continue
1945
+ probe = await _probe_cloud_model(model_ref)
1946
+ record = {"ok": bool(probe.get("ok")), "reason": probe.get("reason", ""), "ts": now}
1947
+ CLOUD_VERIFY_CACHE[model_ref] = record
1948
+ results[model_ref] = record
1949
+ return results
1950
+
1951
+ @app.get("/health")
1952
+ async def health(request: Request):
1953
+ base = {"status": "ok", "version": "2.1.0", "mode": APP_MODE}
1954
+ if not get_current_user(request) and REQUIRE_AUTH:
1955
+ return base
1956
+ return {
1957
+ **base,
1958
+ "current_model": router.current_model_id,
1959
+ "loaded_models": router.loaded_model_ids,
1960
+ "device": "Apple Silicon MLX" if not IS_PUBLIC_MODE else "Public cloud/API runtime",
1961
+ "features": runtime_features(),
1962
+ "providers": router.detected_cloud_models(),
1963
+ "engines": engine_status(),
1964
+ }
1965
+
1966
+
1967
+ @app.get("/mode")
1968
+ async def mode():
1969
+ return runtime_features()
1970
+
1971
+
1972
+ @app.get("/engines")
1973
+ async def engines():
1974
+ return {"engines": engine_status(), "current": router.current_model_id}
1975
+
1976
+
1977
+ @app.post("/engines/install")
1978
+ async def engines_install(req: InstallEngineRequest, request: Request):
1979
+ require_user(request)
1980
+ return install_engine(req.engine)
1981
+
1982
+ @app.post("/engines/verify-cloud")
1983
+ async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
1984
+ require_user(request)
1985
+ results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
1986
+ return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
1987
+
1988
+
1989
+ @app.post("/engines/pull-model")
1990
+ async def pull_ollama_model(req: PullModelRequest, request: Request):
1991
+ require_user(request)
1992
+ model_ref = req.model.strip()
1993
+ if not model_ref:
1994
+ raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
1995
+
1996
+ if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
1997
+ provider, model_name = model_ref.split(":", 1)
1998
+ provider = provider.strip().lower()
1999
+ model_name = model_name.strip()
2000
+ else:
2001
+ provider, model_name = "local_mlx", model_ref
2002
+
2003
+ if not model_name:
2004
+ raise HTTPException(status_code=400, detail="모델 이름이 비어 있습니다.")
2005
+
2006
+ if provider == "ollama":
2007
+ if not shutil.which("ollama"):
2008
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
2009
+ try:
2010
+ completed = subprocess.run(
2011
+ ["ollama", "pull", model_name],
2012
+ capture_output=True, text=True, timeout=900, check=False,
2013
+ )
2014
+ except subprocess.TimeoutExpired:
2015
+ raise HTTPException(status_code=408, detail="모델 다운로드 시간이 초과되었습니다.")
2016
+ if completed.returncode != 0:
2017
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull 실패")
2018
+ return {"provider": provider, "model": model_name, "returncode": completed.returncode}
2019
+
2020
+ if provider in {"vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
2021
+ if importlib.util.find_spec("huggingface_hub") is None:
2022
+ raise HTTPException(status_code=400, detail="huggingface_hub가 없습니다. 먼저 엔진 설치를 진행해 주세요.")
2023
+ target_dir = Path.home() / ".latticeai" / "hf-models" / model_name.replace("/", "__")
2024
+ target_dir.mkdir(parents=True, exist_ok=True)
2025
+ try:
2026
+ completed = subprocess.run(
2027
+ [sys.executable, "-m", "huggingface_hub", "download", model_name, "--local-dir", str(target_dir)],
2028
+ capture_output=True, text=True, timeout=3600, check=False,
2029
+ )
2030
+ except subprocess.TimeoutExpired:
2031
+ raise HTTPException(status_code=408, detail=f"{provider} 모델 다운로드 시간이 초과되었습니다.")
2032
+ if completed.returncode != 0:
2033
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{provider} 모델 다운로드 실패")
2034
+ return {"provider": provider, "model": model_name, "returncode": completed.returncode, "path": str(target_dir)}
2035
+
2036
+ raise HTTPException(status_code=400, detail=f"{provider} 엔진 모델 다운로드는 아직 자동화되지 않았습니다.")
2037
+
2038
+
2039
+ @app.post("/setup/set-api-key")
2040
+ async def set_api_key(req: SetApiKeyRequest, request: Request):
2041
+ from llm_router import OPENAI_COMPATIBLE_PROVIDERS
2042
+ config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
2043
+ if not config:
2044
+ raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
2045
+ if not req.key.strip():
2046
+ raise HTTPException(status_code=400, detail="API 키가 비어있습니다.")
2047
+ current_user = get_current_user(request)
2048
+ if REQUIRE_AUTH and not current_user:
2049
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
2050
+ # req.user_email 을 통한 타 계정 위조를 방지: 관리자가 아니면 본인 이메일만 허용
2051
+ if req.user_email and req.user_email != current_user:
2052
+ users = load_users()
2053
+ if get_user_role(current_user or "", users) != "admin":
2054
+ raise HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
2055
+ target_email = (req.user_email or current_user or "").strip()
2056
+ if not target_email:
2057
+ raise HTTPException(status_code=400, detail="사용자 식별이 필요합니다. 로그인 후 다시 시도하세요.")
2058
+ set_user_api_key(target_email, req.provider, req.key.strip())
2059
+ return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
2060
+
2061
+
2062
+ @app.get("/models")
2063
+ async def list_models():
2064
+ """HuggingFace 추천 모델 목록 및 로드 상태 반환"""
2065
+ recommended = [
2066
+ # Qwen Series
2067
+ {"id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", "name": "Qwen 2.5 Coder 7B", "tag": "coding", "size": "4.3GB"},
2068
+ {"id": "mlx-community/Qwen2.5-7B-Instruct-4bit", "name": "Qwen 2.5 7B", "tag": "general", "size": "4.3GB"},
2069
+
2070
+ # Llama Series
2071
+ {"id": "mlx-community/Llama-3.2-3B-Instruct-4bit", "name": "Llama 3.2 3B", "tag": "light", "size": "2.0GB"},
2072
+ {"id": "mlx-community/Llama-3.1-8B-Instruct-4bit", "name": "Llama 3.1 8B", "tag": "general", "size": "4.7GB"},
2073
+
2074
+ # Gemma Series
2075
+ {"id": "google/gemma-4-E4B", "name": "Gemma 4 E4B (Latest)", "tag": "next-gen", "size": "Next-Gen"},
2076
+ {"id": "mlx-community/gemma-2-9b-it-4bit", "name": "Gemma 2 9B", "tag": "balanced","size": "5.4GB"},
2077
+ {"id": "mlx-community/gemma-2-2b-it-4bit", "name": "Gemma 2 2B", "tag": "ultra-light", "size": "1.6GB"},
2078
+
2079
+ # Reasoning
2080
+ {"id": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit","name": "DeepSeek R1 (7B)", "tag": "reasoning","size": "4.3GB"},
2081
+ ]
2082
+ return {
2083
+ "recommended": recommended,
2084
+ "cloud": router.detected_cloud_models(),
2085
+ "engines": engine_status(),
2086
+ "loaded": router.loaded_model_ids,
2087
+ "current": router.current_model_id,
2088
+ }
2089
+
2090
+
2091
+ # ── Model Management ───────────────────────────────────────────────────────────
2092
+
2093
+ @app.post("/models/load")
2094
+ async def load_model(req: LoadModelRequest, request: Request):
2095
+ """모델 로드 (이미 로드됐으면 캐시에서 즉시 반환)"""
2096
+ try:
2097
+ model_id = req.model_id
2098
+ requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
2099
+ if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
2100
+ raise HTTPException(
2101
+ status_code=400,
2102
+ detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
2103
+ )
2104
+ if req.engine and req.engine not in {"local_mlx", "mlx"} and ":" not in model_id:
2105
+ model_id = f"{req.engine}:{model_id}"
2106
+ effective_email = (req.user_email or get_current_user(request) or "").strip()
2107
+ user_api_key = None
2108
+ if ":" in model_id:
2109
+ provider = model_id.split(":", 1)[0]
2110
+ user_api_key = get_user_api_key(effective_email, provider)
2111
+ msg = await router.load_model(
2112
+ model_id,
2113
+ req.adapter_path,
2114
+ draft_model_id=req.draft_model_id,
2115
+ api_key_override=user_api_key,
2116
+ owner=effective_email or None,
2117
+ )
2118
+ return {"status": "ok", "message": msg, "current": router.current_model_id}
2119
+ except HTTPException:
2120
+ raise
2121
+ except Exception as e:
2122
+ raise HTTPException(status_code=500, detail=str(e))
2123
+
2124
+
2125
+ @app.post("/models/switch/{model_id:path}")
2126
+ async def switch_model(model_id: str, request: Request):
2127
+ """이미 로드된 모델 중 활성 모델 전환 (즉시, 재로드 없음)"""
2128
+ require_user(request)
2129
+ try:
2130
+ router.switch_model(model_id)
2131
+ return {"status": "ok", "current": router.current_model_id}
2132
+ except KeyError:
2133
+ raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
2134
+
2135
+
2136
+ @app.delete("/models/unload/{model_id:path}")
2137
+ async def unload_model(model_id: str, request: Request):
2138
+ """모델 언로드 → 메모리 해제"""
2139
+ require_user(request)
2140
+ router.unload_model(model_id)
2141
+ return {"status": "ok", "unloaded": model_id}
2142
+
2143
+
2144
+ @app.delete("/models/unload-all")
2145
+ async def unload_all_models(request: Request):
2146
+ """로드된 모든 모델 언로드 → 메모리 해제"""
2147
+ require_user(request)
2148
+ unloaded = router.loaded_model_ids
2149
+ router.unload_all()
2150
+ return {"status": "ok", "unloaded": unloaded}
2151
+
2152
+
2153
+ # ── Chat / Completion ──────────────────────────────────────────────────────────
2154
+
2155
+ @app.post("/chat")
2156
+ async def chat(req: ChatRequest, request: Request):
2157
+ current_user = require_user(request)
2158
+ img_len = len(req.image_data) if req.image_data else 0
2159
+ print(
2160
+ f"🧪 /chat request: stream={req.stream} image_data_len={img_len} "
2161
+ f"message_len={len(req.message or '')}"
2162
+ )
2163
+ effective_email = req.user_email or current_user or None
2164
+ history_user = get_history_user(effective_email, req.user_nickname)
2165
+
2166
+ if is_network_status_request(req.message):
2167
+ history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
2168
+ save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2169
+ try:
2170
+ answer = format_network_status(network_status())
2171
+ except ToolError as exc:
2172
+ answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
2173
+ save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2174
+ if req.source != "telegram":
2175
+ asyncio.create_task(broadcast_web_chat("user", req.message))
2176
+ asyncio.create_task(broadcast_web_chat("assistant", answer))
2177
+ if req.stream:
2178
+ return StreamingResponse(
2179
+ single_text_stream(answer),
2180
+ media_type="text/event-stream",
2181
+ headers={"X-Model": "network_status"},
2182
+ )
2183
+ return JSONResponse(content={"response": answer})
2184
+
2185
+ if is_clear_command(req.message):
2186
+ command = req.message.strip().lower()
2187
+ if command == "/clear_all":
2188
+ result = clear_history(0)
2189
+ answer = f"전체 대화 기록을 지웠습니다. 삭제 {result.get('removed', 0)}개."
2190
+ else:
2191
+ if req.conversation_id:
2192
+ result = clear_conversation(req.conversation_id)
2193
+ answer = f"현재 대화방 기록을 지웠습니다. 삭제 {result.get('removed', 0)}개."
2194
+ else:
2195
+ result = clear_history(0)
2196
+ answer = f"대화 기록을 지웠습니다. 삭제 {result.get('removed', 0)}개."
2197
+ if req.stream:
2198
+ return StreamingResponse(
2199
+ single_text_stream(answer),
2200
+ media_type="text/event-stream",
2201
+ headers={"X-Model": "history"},
2202
+ )
2203
+ return JSONResponse(content={"response": answer})
2204
+
2205
+ if is_current_url_request(req.message) and req.client_url:
2206
+ answer = f"현재 페이지 URL: {req.client_url}"
2207
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2208
+ save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2209
+ if req.source != "telegram":
2210
+ asyncio.create_task(broadcast_web_chat("user", req.message))
2211
+ asyncio.create_task(broadcast_web_chat("assistant", answer))
2212
+ if req.stream:
2213
+ return StreamingResponse(
2214
+ single_text_stream(answer),
2215
+ media_type="text/event-stream",
2216
+ headers={"X-Model": "client_url"},
2217
+ )
2218
+ return JSONResponse(content={"response": answer})
2219
+
2220
+ if not router.current_model_id:
2221
+ detail = "No model loaded. Call /models/load first."
2222
+ if IS_PUBLIC_MODE:
2223
+ detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
2224
+ raise HTTPException(status_code=400, detail=detail)
2225
+
2226
+ if req.model and req.model != router.current_model_id:
2227
+ if req.model not in router.loaded_model_ids:
2228
+ raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
2229
+ router.switch_model(req.model)
2230
+
2231
+ lang = detect_language(req.message)
2232
+ context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
2233
+ try:
2234
+ knowledge_context = gardener.get_relevant_context(req.message)
2235
+ if knowledge_context:
2236
+ context += f"\n\n[LOCAL KNOWLEDGE BASE]\n{knowledge_context}"
2237
+ print(f"📖 Context reinforced with local knowledge.")
2238
+ except Exception as e:
2239
+ logging.warning("Knowledge reinforcement skipped: %s", e)
2240
+
2241
+ if req.image_data:
2242
+ screenshot_context = extract_screenshot_context(req.image_data)
2243
+ if screenshot_context:
2244
+ context += f"\n\n{screenshot_context}"
2245
+
2246
+ history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
2247
+ save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2248
+ if req.source != "telegram":
2249
+ asyncio.create_task(broadcast_web_chat("user", req.message))
2250
+
2251
+ if req.stream:
2252
+ recent_context = build_recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
2253
+ stream_context = context
2254
+ if recent_context:
2255
+ stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
2256
+ return StreamingResponse(
2257
+ _stream_chat(req, stream_context, req.image_data),
2258
+ media_type="text/event-stream",
2259
+ headers={"X-Model": router.current_model_id},
2260
+ )
2261
+ else:
2262
+ if req.image_data:
2263
+ recent_context = build_recent_chat_context(
2264
+ limit=6,
2265
+ include_image_missing_replies=False,
2266
+ user_email=effective_email,
2267
+ conversation_id=req.conversation_id,
2268
+ )
2269
+ full_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip() if recent_context else context
2270
+ else:
2271
+ history_context = build_recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
2272
+ full_context = f"{history_context}\n{context}" if context else history_context
2273
+
2274
+ result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
2275
+
2276
+ save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2277
+ if req.source != "telegram":
2278
+ asyncio.create_task(broadcast_web_chat("assistant", str(result)))
2279
+
2280
+ return JSONResponse(content={"response": str(result)})
2281
+
2282
+
2283
+ @app.get("/history")
2284
+ async def fetch_history(request: Request):
2285
+ """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
2286
+ require_user(request)
2287
+ return get_history()
2288
+
2289
+ @app.get("/history/conversations")
2290
+ async def fetch_history_conversations(request: Request):
2291
+ """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
2292
+ require_user(request)
2293
+ return group_history_conversations()
2294
+
2295
+ @app.get("/history/conversations/{conversation_id:path}")
2296
+ async def fetch_history_conversation(conversation_id: str, request: Request):
2297
+ """선택한 대화의 메시지를 반환합니다."""
2298
+ require_user(request)
2299
+ messages = get_conversation_messages(conversation_id)
2300
+ if not messages:
2301
+ raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
2302
+ return {"id": conversation_id, "messages": messages}
2303
+
2304
+
2305
+ @app.delete("/history/conversations/{conversation_id:path}")
2306
+ async def delete_history_conversation(conversation_id: str, request: Request):
2307
+ """선택한 대화방의 메시지만 삭제합니다."""
2308
+ require_user(request)
2309
+ return clear_conversation(conversation_id, request.query_params.get("started_at"))
2310
+
2311
+
2312
+ @app.delete("/history")
2313
+ async def delete_history(request: Request, keep_last: int = 0):
2314
+ require_user(request)
2315
+ return clear_history(keep_last)
2316
+
2317
+
2318
+ async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
2319
+ full_response = ""
2320
+ async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
2321
+ clean_chunk = chunk
2322
+ if hasattr(chunk, "text"):
2323
+ clean_chunk = chunk.text
2324
+ elif isinstance(chunk, str) and "text='" in chunk:
2325
+ try:
2326
+ clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
2327
+ except Exception:
2328
+ pass
2329
+
2330
+ full_response += str(clean_chunk)
2331
+ yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
2332
+ history_user = get_history_user(req.user_email, req.user_nickname)
2333
+ save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2334
+ if req.source != "telegram":
2335
+ asyncio.create_task(broadcast_web_chat("assistant", full_response))
2336
+ yield "data: [DONE]\n\n"
2337
+
2338
+
2339
+ # ── Local Computer Agent ──────────────────────────────────────────────────────
2340
+
2341
+ AGENT_SYSTEM_PROMPT = """You are Lattice AI Agent, a local computer-use coding assistant.
2342
+ You can work only inside the agent workspace.
2343
+
2344
+ Available actions:
2345
+ - list_dir: {"action":"list_dir","args":{"path":"."}}
2346
+ - workspace_tree: {"action":"workspace_tree","args":{"path":".","max_depth":3}}
2347
+ - read_file: {"action":"read_file","args":{"path":"relative/path.txt"}}
2348
+ - write_file: {"action":"write_file","args":{"path":"relative/path.txt","content":"complete file content"}}
2349
+ - search_files: {"action":"search_files","args":{"query":"text","path":".","max_results":20}}
2350
+ - clear_history: {"action":"clear_history","args":{"keep_last":0}}
2351
+ - inspect_html: {"action":"inspect_html","args":{"path":"index.html"}}
2352
+ - preview_url: {"action":"preview_url","args":{"path":"index.html"}}
2353
+ - create_docx: {"action":"create_docx","args":{"title":"title","body":"paragraphs","filename":"document.docx"}}
2354
+ - create_xlsx: {"action":"create_xlsx","args":{"rows":[["A","B"],[1,2]],"filename":"spreadsheet.xlsx","sheet_name":"Sheet1"}}
2355
+ - create_pptx: {"action":"create_pptx","args":{"title":"title","slides":[{"title":"Slide","bullets":["point"]}],"filename":"presentation.pptx"}}
2356
+ - create_pdf: {"action":"create_pdf","args":{"title":"title","body":"paragraphs","filename":"document.pdf"}}
2357
+ - local_list: {"action":"local_list","args":{"path":"/Users/username/Downloads"}} — lists any local folder (UI will request user permission first)
2358
+ - local_read: {"action":"local_read","args":{"path":"/Users/username/Documents/note.txt"}} — reads any local file (UI will request user permission first)
2359
+ - local_write: {"action":"local_write","args":{"path":"/Users/username/Desktop/output.txt","content":"..."}} — writes any local file (UI will request user permission first)
2360
+ - read_document: {"action":"read_document","args":{"path":"/absolute/path/to/file.pdf"}} — extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV
2361
+ - computer_screenshot: {"action":"computer_screenshot","args":{}} — capture current screen as base64 PNG
2362
+ - computer_open_app: {"action":"computer_open_app","args":{"app":"Google Chrome"}} — open or focus a Mac app
2363
+ - computer_open_url: {"action":"computer_open_url","args":{"url":"https://example.com","app":"Google Chrome"}} — open URL in app
2364
+ - computer_click: {"action":"computer_click","args":{"x":500,"y":300,"button":"left","double":false}}
2365
+ - computer_type: {"action":"computer_type","args":{"text":"hello"}}
2366
+ - computer_key: {"action":"computer_key","args":{"key":"command+c"}} — e.g. return, escape, tab, command+v
2367
+ - computer_scroll: {"action":"computer_scroll","args":{"x":500,"y":300,"direction":"down","clicks":3}}
2368
+ - computer_move: {"action":"computer_move","args":{"x":500,"y":300}}
2369
+ - computer_drag: {"action":"computer_drag","args":{"x1":100,"y1":100,"x2":500,"y2":500}}
2370
+ - computer_status: {"action":"computer_status","args":{}} — check if Computer Use is available
2371
+ - chrome_status: {"action":"chrome_status","args":{}}
2372
+ - computer_use_status: {"action":"computer_use_status","args":{}}
2373
+ - knowledge_save: {"action":"knowledge_save","args":{"folder":"30_Projects","title":"short title","content":"note"}}
2374
+ - knowledge_search: {"action":"knowledge_search","args":{"query":"keyword","max_results":5}}
2375
+ - knowledge_tree: {"action":"knowledge_tree","args":{}}
2376
+ - obsidian_save: {"action":"obsidian_save","args":{"folder":"30_Projects","title":"short title","content":"note"}}
2377
+ - obsidian_search: {"action":"obsidian_search","args":{"query":"keyword","max_results":5}}
2378
+ - obsidian_tree: {"action":"obsidian_tree","args":{}}
2379
+ - git_status: {"action":"git_status","args":{}}
2380
+ - git_diff: {"action":"git_diff","args":{"path":"optional/relative/path"}}
2381
+ - git_log: {"action":"git_log","args":{"max_count":5}}
2382
+ - git_show: {"action":"git_show","args":{"revision":"HEAD"}}
2383
+ - network_status: {"action":"network_status","args":{}} — get current local/private IP, public IP, hostname, and Wi-Fi info
2384
+ - run_command: {"action":"run_command","args":{"command":"python3 app.py","cwd":"."}}
2385
+ - build_project: {"action":"build_project","args":{"cwd":".","script":"build"}}
2386
+ - deploy_project: {"action":"deploy_project","args":{"cwd":".","script":"deploy"}}
2387
+ - final: {"action":"final","message":"short Korean summary of what you did"}
2388
+
2389
+ Rules:
2390
+ - Respond with exactly one JSON object. No markdown, no code fences, no extra text.
2391
+ - Use relative paths only.
2392
+ - Create complete files, not fragments.
2393
+ - Prefer simple, verifiable steps.
2394
+ - Use inspect_html and preview_url for generated web UI.
2395
+ - Use build_project when the user asks to build, compile, typecheck, or run a package build script.
2396
+ - Use deploy_project when the user asks to deploy, preview, or release and package.json defines that script.
2397
+ - Do not claim you cannot build or deploy. If a script, token, or platform config is missing, inspect the workspace and explain the exact missing piece.
2398
+ - Use knowledge tools when the user asks to remember, search memory, or organize project context.
2399
+ - Use run_command for local inspection, tests, and short development commands after files are written.
2400
+ - Use clear_history when the user asks to forget, clear, delete, reset, or speed up chat history.
2401
+ - Git is read-only: status, diff, log, and show only. Never commit, push, pull, fetch, clone, reset, or checkout.
2402
+ - If the user asks for something unsafe or outside the workspace, explain the limitation with final.
2403
+ - IMPORTANT: When user asks to create any document (docx, pdf, xlsx, pptx, word, excel, powerpoint, 문서, 파일, 엑셀, 파워포인트, PPT, 피피티), ALWAYS use the appropriate create_* action immediately with full, rich content. Never say you cannot create files.
2404
+ """
2405
+
2406
+
2407
+ _FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file"}
2408
+
2409
+ def _collect_created_files(transcript: list) -> list:
2410
+ files = []
2411
+ for step in transcript:
2412
+ if step.get("action") in _FILE_CREATE_ACTIONS:
2413
+ result = step.get("result", {})
2414
+ path = result.get("path")
2415
+ if path:
2416
+ files.append({
2417
+ "path": path,
2418
+ "filename": Path(path).name,
2419
+ "bytes": result.get("bytes", 0),
2420
+ "action": step["action"],
2421
+ })
2422
+ return files
2423
+
2424
+
2425
+ def _extract_agent_action(raw: str) -> Dict:
2426
+ text = raw.strip()
2427
+ fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
2428
+ if fenced:
2429
+ text = fenced.group(1).strip()
2430
+ elif not text.startswith("{"):
2431
+ start = text.find("{")
2432
+ end = text.rfind("}")
2433
+ if start >= 0 and end > start:
2434
+ text = text[start : end + 1]
2435
+
2436
+ try:
2437
+ action = json.loads(text)
2438
+ except json.JSONDecodeError as exc:
2439
+ raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
2440
+
2441
+ if not isinstance(action, dict) or "action" not in action:
2442
+ raise ValueError("Agent JSON must include an action field.")
2443
+ return action
2444
+
2445
+
2446
+ @app.post("/agent")
2447
+ async def agent(req: AgentRequest, request: Request):
2448
+ """Natural-language local agent loop for Telegram and future clients."""
2449
+ require_user(request)
2450
+ if not router.current_model_id:
2451
+ raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
2452
+
2453
+ ensure_agent_root()
2454
+ transcript = []
2455
+ max_steps = max(1, min(req.max_steps, 10))
2456
+ lang = detect_language(req.message)
2457
+ lang_hint = _LANG_HINT[lang]
2458
+
2459
+ for step in range(max_steps):
2460
+ recent_context = build_recent_chat_context(conversation_id=req.conversation_id)
2461
+ context = (
2462
+ f"{AGENT_SYSTEM_PROMPT}\n\n"
2463
+ f"[LANGUAGE: {lang_hint}]\n\n"
2464
+ f"Workspace root: {AGENT_ROOT}\n\n"
2465
+ f"Recent conversation:\n{recent_context or '(none)'}\n\n"
2466
+ f"User request:\n{req.message}\n\n"
2467
+ f"Previous tool results:\n{json.dumps(transcript, ensure_ascii=False, indent=2)}"
2468
+ )
2469
+ raw = await router.generate(
2470
+ message="Choose the next agent action.",
2471
+ context=context,
2472
+ max_tokens=4096,
2473
+ temperature=req.temperature,
2474
+ )
2475
+
2476
+ try:
2477
+ action = _extract_agent_action(str(raw))
2478
+ except ValueError as exc:
2479
+ transcript.append({"step": step + 1, "action": "parse_error", "raw": str(raw), "error": str(exc)})
2480
+ return JSONResponse(
2481
+ status_code=500,
2482
+ content={"status": "error", "error": str(exc), "raw": str(raw), "steps": transcript},
2483
+ )
2484
+
2485
+ name = action.get("action")
2486
+ if name == "final":
2487
+ message = action.get("message", "작업을 완료했습니다.")
2488
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
2489
+ save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
2490
+ created_files = _collect_created_files(transcript)
2491
+ return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
2492
+
2493
+ if name == "clear_history":
2494
+ result = clear_history((action.get("args") or {}).get("keep_last", 0))
2495
+ transcript.append({"step": step + 1, "action": name, "args": action.get("args") or {}, "result": result})
2496
+ continue
2497
+
2498
+ try:
2499
+ result = execute_tool(name, action.get("args") or {})
2500
+ transcript.append({"step": step + 1, "action": name, "args": action.get("args") or {}, "result": result})
2501
+ except (ToolError, KeyError, TypeError) as exc:
2502
+ transcript.append({"step": step + 1, "action": name, "args": action.get("args") or {}, "error": str(exc)})
2503
+
2504
+ summary_context = (
2505
+ f"{AGENT_SYSTEM_PROMPT}\n\n"
2506
+ f"Recent conversation:\n{build_recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
2507
+ f"User request:\n{req.message}\n\n"
2508
+ f"Tool transcript:\n{json.dumps(transcript, ensure_ascii=False, indent=2)}"
2509
+ )
2510
+ summary = await router.generate(
2511
+ message='Return only {"action":"final","message":"..."} summarizing the current result in Korean.',
2512
+ context=summary_context,
2513
+ max_tokens=1024,
2514
+ temperature=0.1,
2515
+ )
2516
+ try:
2517
+ final_action = _extract_agent_action(str(summary))
2518
+ message = final_action.get("message", str(summary))
2519
+ except ValueError:
2520
+ message = str(summary)
2521
+
2522
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
2523
+ save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
2524
+ created_files = _collect_created_files(transcript)
2525
+ return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
2526
+
2527
+
2528
+ # ── Direct Tool API ───────────────────────────────────────────────────────────
2529
+
2530
+ def _tool_response(fn, *args):
2531
+ try:
2532
+ return {"status": "ok", "workspace": str(AGENT_ROOT), "result": fn(*args)}
2533
+ except ToolError as exc:
2534
+ raise HTTPException(status_code=400, detail=str(exc))
2535
+
2536
+
2537
+ @app.post("/tools/list_dir")
2538
+ async def tools_list_dir(req: ToolPathRequest, request: Request):
2539
+ require_user(request)
2540
+ return _tool_response(list_dir, req.path)
2541
+
2542
+
2543
+ @app.post("/tools/workspace_tree")
2544
+ async def tools_workspace_tree(req: ToolWorkspaceTreeRequest, request: Request):
2545
+ require_user(request)
2546
+ return _tool_response(workspace_tree, req.path, req.max_depth)
2547
+
2548
+
2549
+ @app.post("/tools/read_file")
2550
+ async def tools_read_file(req: ToolPathRequest, request: Request):
2551
+ require_user(request)
2552
+ return _tool_response(read_file, req.path)
2553
+
2554
+
2555
+ @app.post("/tools/write_file")
2556
+ async def tools_write_file(req: ToolWriteFileRequest, request: Request):
2557
+ require_user(request)
2558
+ return _tool_response(write_file, req.path, req.content)
2559
+
2560
+
2561
+ @app.post("/tools/search_files")
2562
+ async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
2563
+ require_user(request)
2564
+ return _tool_response(search_files, req.query, req.path, req.max_results)
2565
+
2566
+
2567
+ @app.post("/tools/clear_history")
2568
+ async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
2569
+ require_user(request)
2570
+ return clear_history(req.keep_last)
2571
+
2572
+
2573
+ @app.post("/tools/inspect_html")
2574
+ async def tools_inspect_html(req: ToolPathRequest, request: Request):
2575
+ require_user(request)
2576
+ return _tool_response(inspect_html, req.path)
2577
+
2578
+
2579
+ @app.post("/tools/preview_url")
2580
+ async def tools_preview_url(req: ToolPathRequest, request: Request):
2581
+ require_user(request)
2582
+ return _tool_response(preview_url, req.path)
2583
+
2584
+
2585
+ @app.post("/tools/create_docx")
2586
+ async def tools_create_docx(req: ToolDocxRequest, request: Request):
2587
+ require_user(request)
2588
+ return _tool_response(create_docx, req.title, req.body, req.filename)
2589
+
2590
+
2591
+ @app.post("/tools/create_xlsx")
2592
+ async def tools_create_xlsx(req: ToolXlsxRequest, request: Request):
2593
+ require_user(request)
2594
+ return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name)
2595
+
2596
+
2597
+ @app.post("/tools/create_pptx")
2598
+ async def tools_create_pptx(req: ToolPptxRequest, request: Request):
2599
+ require_user(request)
2600
+ return _tool_response(create_pptx, req.title, req.slides, req.filename)
2601
+
2602
+
2603
+ @app.post("/tools/create_pdf")
2604
+ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
2605
+ require_user(request)
2606
+ return _tool_response(create_pdf, req.title, req.body, req.filename)
2607
+
2608
+
2609
+ @app.get("/tools/download")
2610
+ async def tools_download(path: str, request: Request):
2611
+ """Serve a generated file from agent workspace for download."""
2612
+ require_user(request)
2613
+ from urllib.parse import unquote
2614
+ rel = unquote(path).lstrip("/")
2615
+ target = (AGENT_ROOT / rel).resolve()
2616
+ if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
2617
+ raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
2618
+ if not target.exists() or not target.is_file():
2619
+ raise HTTPException(status_code=404, detail="파일이 없습니다.")
2620
+ return FileResponse(
2621
+ path=target,
2622
+ filename=target.name,
2623
+ media_type="application/octet-stream",
2624
+ )
2625
+
2626
+
2627
+ @app.post("/upload/document")
2628
+ async def upload_document(request: Request, file: UploadFile = File(...)):
2629
+ require_user(request)
2630
+ """Upload a document and extract text (PDF, DOCX, XLSX, PPTX, TXT, MD, CSV)."""
2631
+ suffix = Path(file.filename or "upload").suffix.lower()
2632
+ allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
2633
+ if suffix not in allowed:
2634
+ raise HTTPException(status_code=400, detail=f"지원하지 않는 형식: {suffix}")
2635
+ contents = await file.read()
2636
+ if len(contents) > 10 * 1024 * 1024:
2637
+ raise HTTPException(status_code=400, detail="파일이 너무 큽니다. 최대 10MB.")
2638
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
2639
+ tmp.write(contents)
2640
+ tmp_path = tmp.name
2641
+ try:
2642
+ result = read_document(tmp_path)
2643
+ except ToolError as exc:
2644
+ raise HTTPException(status_code=400, detail=str(exc))
2645
+ finally:
2646
+ try:
2647
+ Path(tmp_path).unlink()
2648
+ except OSError:
2649
+ pass
2650
+ result["original_filename"] = file.filename
2651
+ return result
2652
+
2653
+
2654
+ _PERMISSION_ACTION_LABELS = {
2655
+ "list": "폴더 목록 보기",
2656
+ "read": "파일 읽기",
2657
+ "write": "파일 쓰기",
2658
+ }
2659
+
2660
+ def _local_permission_response(path: str, action: str) -> dict:
2661
+ return {
2662
+ "permission_required": True,
2663
+ "path": path,
2664
+ "action": action,
2665
+ "action_label": _PERMISSION_ACTION_LABELS.get(action, action),
2666
+ "message": f"AI가 '{path}' 에 대한 {_PERMISSION_ACTION_LABELS.get(action, action)} 권한을 요청합니다.",
2667
+ }
2668
+
2669
+
2670
+ @app.post("/local/list")
2671
+ async def local_list_endpoint(req: LocalAccessRequest, request: Request):
2672
+ require_user(request)
2673
+ if not req.approved:
2674
+ return _local_permission_response(req.path, "list")
2675
+ return _tool_response(local_list, req.path)
2676
+
2677
+
2678
+ @app.post("/local/read")
2679
+ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
2680
+ require_user(request)
2681
+ if not req.approved:
2682
+ return _local_permission_response(req.path, "read")
2683
+ return _tool_response(local_read, req.path)
2684
+
2685
+
2686
+ @app.post("/local/write")
2687
+ async def local_write_endpoint(req: LocalWriteRequest, request: Request):
2688
+ require_user(request)
2689
+ if not req.approved:
2690
+ return _local_permission_response(req.path, "write")
2691
+ return _tool_response(local_write, req.path, req.content)
2692
+
2693
+
2694
+ @app.get("/tools/chrome_status")
2695
+ async def tools_chrome_status(request: Request):
2696
+ require_user(request)
2697
+ return _tool_response(desktop_bridge_status)
2698
+
2699
+
2700
+ @app.get("/tools/computer_use_status")
2701
+ async def tools_computer_use_status(request: Request):
2702
+ require_user(request)
2703
+ return _tool_response(computer_status)
2704
+
2705
+
2706
+ # ── Computer Use API ──────────────────────────────────────────────────────────
2707
+
2708
+ CU_SYSTEM_PROMPT = """You are Lattice AI Computer Use Agent. You control the Mac desktop using tools.
2709
+ Prefer non-visual direct actions when possible. Use screenshots only when you must inspect visible UI state or choose screen coordinates.
2710
+
2711
+ Available actions:
2712
+ - computer_screenshot: {"action":"computer_screenshot","args":{}} — capture screen, returns screenshot_b64
2713
+ - computer_open_app: {"action":"computer_open_app","args":{"app":"Google Chrome"}} — open or focus a Mac app
2714
+ - computer_open_url: {"action":"computer_open_url","args":{"url":"https://example.com","app":"Google Chrome"}} — open URL in app
2715
+ - computer_click: {"action":"computer_click","args":{"x":500,"y":300,"button":"left","double":false}}
2716
+ - computer_type: {"action":"computer_type","args":{"text":"hello world","interval":0.04}}
2717
+ - computer_key: {"action":"computer_key","args":{"key":"return"}} — keys: return, escape, tab, space, command+c, etc.
2718
+ - computer_scroll: {"action":"computer_scroll","args":{"x":500,"y":300,"direction":"down","clicks":3}}
2719
+ - computer_move: {"action":"computer_move","args":{"x":500,"y":300}}
2720
+ - computer_drag: {"action":"computer_drag","args":{"x1":100,"y1":100,"x2":500,"y2":500}}
2721
+ - final: {"action":"final","message":"Korean summary of what was accomplished"}
2722
+
2723
+ Rules:
2724
+ - Respond with exactly ONE JSON object. No markdown, no extra text.
2725
+ - Do not take screenshots for simple app launch, URL opening, keyboard shortcuts, or non-visual tasks.
2726
+ - Take a screenshot before coordinate-based clicks/drags or when the task explicitly asks you to inspect the screen.
2727
+ - After coordinate-based clicking or typing into an unknown focused field, take a screenshot only if verification is necessary.
2728
+ - Use coordinates relative to the screen (0,0 is top-left).
2729
+ - If a UI element is not visible, scroll or search for it first.
2730
+ - macOS Accessibility permission required for mouse/keyboard control.
2731
+ """
2732
+
2733
+ class CuAgentRequest(BaseModel):
2734
+ task: str
2735
+ conversation_id: Optional[str] = None
2736
+ max_steps: int = 15
2737
+ temperature: float = 0.1
2738
+
2739
+ class CuClickRequest(BaseModel):
2740
+ x: int
2741
+ y: int
2742
+ button: str = "left"
2743
+ double: bool = False
2744
+
2745
+ class CuOpenAppRequest(BaseModel):
2746
+ app: str = "Google Chrome"
2747
+
2748
+ class CuOpenUrlRequest(BaseModel):
2749
+ url: str
2750
+ app: str = "Google Chrome"
2751
+
2752
+ class CuTypeRequest(BaseModel):
2753
+ text: str
2754
+ interval: float = 0.04
2755
+
2756
+ class CuKeyRequest(BaseModel):
2757
+ key: str
2758
+
2759
+ class CuScrollRequest(BaseModel):
2760
+ x: int
2761
+ y: int
2762
+ direction: str = "down"
2763
+ clicks: int = 3
2764
+
2765
+ class CuMoveRequest(BaseModel):
2766
+ x: int
2767
+ y: int
2768
+
2769
+ class CuDragRequest(BaseModel):
2770
+ x1: int
2771
+ y1: int
2772
+ x2: int
2773
+ y2: int
2774
+
2775
+
2776
+ @app.get("/cu/status")
2777
+ async def cu_status(request: Request):
2778
+ require_user(request)
2779
+ try:
2780
+ return computer_status()
2781
+ except ToolError as exc:
2782
+ raise HTTPException(status_code=400, detail=str(exc))
2783
+
2784
+
2785
+ @app.get("/cu/screenshot")
2786
+ async def cu_screenshot(request: Request):
2787
+ require_user(request)
2788
+ try:
2789
+ return computer_screenshot()
2790
+ except ToolError as exc:
2791
+ raise HTTPException(status_code=400, detail=str(exc))
2792
+
2793
+
2794
+ @app.post("/cu/open_app")
2795
+ async def cu_open_app(req: CuOpenAppRequest, request: Request):
2796
+ require_user(request)
2797
+ return _tool_response(computer_open_app, req.app)
2798
+
2799
+
2800
+ @app.post("/cu/open_url")
2801
+ async def cu_open_url(req: CuOpenUrlRequest, request: Request):
2802
+ require_user(request)
2803
+ return _tool_response(computer_open_url, req.url, req.app)
2804
+
2805
+
2806
+ @app.post("/cu/click")
2807
+ async def cu_click(req: CuClickRequest, request: Request):
2808
+ require_user(request)
2809
+ return _tool_response(computer_click, req.x, req.y, req.button, req.double)
2810
+
2811
+
2812
+ @app.post("/cu/type")
2813
+ async def cu_type(req: CuTypeRequest, request: Request):
2814
+ require_user(request)
2815
+ return _tool_response(computer_type, req.text, req.interval)
2816
+
2817
+
2818
+ @app.post("/cu/key")
2819
+ async def cu_key(req: CuKeyRequest, request: Request):
2820
+ require_user(request)
2821
+ return _tool_response(computer_key, req.key)
2822
+
2823
+
2824
+ @app.post("/cu/scroll")
2825
+ async def cu_scroll(req: CuScrollRequest, request: Request):
2826
+ require_user(request)
2827
+ return _tool_response(computer_scroll, req.x, req.y, req.direction, req.clicks)
2828
+
2829
+
2830
+ @app.post("/cu/move")
2831
+ async def cu_move(req: CuMoveRequest, request: Request):
2832
+ require_user(request)
2833
+ return _tool_response(computer_move, req.x, req.y)
2834
+
2835
+
2836
+ @app.post("/cu/drag")
2837
+ async def cu_drag(req: CuDragRequest, request: Request):
2838
+ require_user(request)
2839
+ return _tool_response(computer_drag, req.x1, req.y1, req.x2, req.y2)
2840
+
2841
+
2842
+ @app.post("/cu/agent")
2843
+ async def cu_agent(req: CuAgentRequest, request: Request):
2844
+ """SSE streaming Computer Use agent loop."""
2845
+ require_user(request)
2846
+ async def _stream():
2847
+ task_lower = (req.task or "").lower()
2848
+ url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
2849
+
2850
+ def _send(event: str, data: dict) -> str:
2851
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
2852
+
2853
+ if ("chrome" in task_lower or "크롬" in task_lower) and any(word in task_lower for word in ["open", "열", "켜", "실행", "띄"]):
2854
+ yield _send("start", {"task": req.task, "max_steps": 1})
2855
+ try:
2856
+ if url_match:
2857
+ url = url_match.group(1)
2858
+ yield _send("action", {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}})
2859
+ result = computer_open_url(url, "Google Chrome")
2860
+ yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
2861
+ message = f"Google Chrome에서 {url}을 열었습니다."
2862
+ action_name = "computer_open_url"
2863
+ else:
2864
+ yield _send("action", {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}})
2865
+ result = computer_open_app("Google Chrome")
2866
+ yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
2867
+ message = "Google Chrome을 열었습니다."
2868
+ action_name = "computer_open_app"
2869
+ save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
2870
+ save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
2871
+ yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
2872
+ except ToolError as exc:
2873
+ yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
2874
+ return
2875
+
2876
+ if not router.current_model_id:
2877
+ yield _send("error", {"error": "No model loaded."})
2878
+ return
2879
+
2880
+ transcript = []
2881
+ last_screenshot_b64: Optional[str] = None
2882
+ max_steps = max(1, min(req.max_steps, 20))
2883
+
2884
+ yield _send("start", {"task": req.task, "max_steps": max_steps})
2885
+
2886
+ for step in range(max_steps):
2887
+ context = (
2888
+ f"{CU_SYSTEM_PROMPT}\n\n"
2889
+ f"Task: {req.task}\n\n"
2890
+ f"Steps completed so far:\n{json.dumps(transcript, ensure_ascii=False, indent=2)}"
2891
+ )
2892
+ raw = await router.generate(
2893
+ message="Choose the next computer use action.",
2894
+ context=context,
2895
+ image_data=last_screenshot_b64,
2896
+ max_tokens=1024,
2897
+ temperature=req.temperature,
2898
+ )
2899
+
2900
+ try:
2901
+ action = _extract_agent_action(str(raw))
2902
+ except ValueError as exc:
2903
+ yield _send("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
2904
+ break
2905
+
2906
+ name = action.get("action")
2907
+ args = action.get("args") or {}
2908
+
2909
+ if name == "final":
2910
+ message = action.get("message", "작업을 완료했습니다.")
2911
+ save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
2912
+ save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
2913
+ yield _send("final", {"message": message, "steps": transcript})
2914
+ return
2915
+
2916
+ yield _send("action", {"step": step + 1, "action": name, "args": args})
2917
+
2918
+ try:
2919
+ result = execute_tool(name, args)
2920
+ # store screenshot for next VLM call
2921
+ if name == "computer_screenshot" and "screenshot_b64" in result:
2922
+ last_screenshot_b64 = result["screenshot_b64"]
2923
+ # strip b64 from transcript to keep it small
2924
+ result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
2925
+ result_summary["screenshot_captured"] = True
2926
+ transcript.append({"step": step + 1, "action": name, "args": args, "result": result_summary})
2927
+ yield _send("screenshot", {"step": step + 1, "screenshot_b64": last_screenshot_b64,
2928
+ "width": result.get("screen_width"), "height": result.get("screen_height")})
2929
+ else:
2930
+ last_screenshot_b64 = None
2931
+ transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
2932
+ yield _send("result", {"step": step + 1, "action": name, "result": result})
2933
+ except (ToolError, KeyError, TypeError) as exc:
2934
+ error_str = str(exc)
2935
+ transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
2936
+ yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
2937
+
2938
+ yield _send("done", {"steps": len(transcript), "transcript": transcript})
2939
+
2940
+ return StreamingResponse(
2941
+ _stream(),
2942
+ media_type="text/event-stream",
2943
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
2944
+ )
2945
+
2946
+
2947
+ @app.post("/tools/knowledge_save")
2948
+ async def tools_knowledge_save(req: ToolKnowledgeSaveRequest, request: Request):
2949
+ require_user(request)
2950
+ return _tool_response(knowledge_save, req.content, req.folder, req.title)
2951
+
2952
+
2953
+ @app.post("/tools/knowledge_search")
2954
+ async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
2955
+ require_user(request)
2956
+ return _tool_response(knowledge_search, req.query, req.max_results)
2957
+
2958
+
2959
+ @app.get("/tools/knowledge_tree")
2960
+ async def tools_knowledge_tree(request: Request):
2961
+ require_user(request)
2962
+ return _tool_response(knowledge_tree)
2963
+
2964
+
2965
+ @app.post("/tools/obsidian_save")
2966
+ async def tools_obsidian_save(req: ToolKnowledgeSaveRequest, request: Request):
2967
+ require_user(request)
2968
+ return _tool_response(obsidian_save, req.content, req.folder, req.title)
2969
+
2970
+
2971
+ @app.post("/tools/obsidian_search")
2972
+ async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
2973
+ require_user(request)
2974
+ return _tool_response(obsidian_search, req.query, req.max_results)
2975
+
2976
+
2977
+ @app.get("/tools/obsidian_tree")
2978
+ async def tools_obsidian_tree(request: Request):
2979
+ require_user(request)
2980
+ return _tool_response(obsidian_tree)
2981
+
2982
+
2983
+ @app.get("/obsidian/status")
2984
+ async def obsidian_status(request: Request):
2985
+ require_user(request)
2986
+ return {
2987
+ "status": "ok",
2988
+ "vault_root": str(BRAIN_DIR),
2989
+ "folders": [path.name for path in BRAIN_DIR.iterdir() if path.is_dir()] if BRAIN_DIR.exists() else [],
2990
+ "ocr_engine": shutil.which("tesseract") or None,
2991
+ }
2992
+
2993
+
2994
+ @app.get("/tools/git_status")
2995
+ async def tools_git_status(request: Request):
2996
+ require_user(request)
2997
+ return _tool_response(git_status)
2998
+
2999
+
3000
+ @app.post("/tools/git_diff")
3001
+ async def tools_git_diff(req: ToolGitDiffRequest, request: Request):
3002
+ require_user(request)
3003
+ return _tool_response(git_diff, req.path, req.cwd)
3004
+
3005
+
3006
+ @app.post("/tools/git_log")
3007
+ async def tools_git_log(req: ToolGitLogRequest, request: Request):
3008
+ require_user(request)
3009
+ return _tool_response(git_log, req.max_count, req.cwd)
3010
+
3011
+
3012
+ @app.post("/tools/git_show")
3013
+ async def tools_git_show(req: ToolGitShowRequest, request: Request):
3014
+ require_user(request)
3015
+ return _tool_response(git_show, req.revision, req.cwd)
3016
+
3017
+
3018
+ @app.post("/tools/run_command")
3019
+ async def tools_run_command(req: ToolRunCommandRequest, request: Request):
3020
+ require_user(request)
3021
+ return _tool_response(run_command, req.command, req.cwd)
3022
+
3023
+
3024
+ @app.get("/tools/network_status")
3025
+ async def tools_network_status(request: Request):
3026
+ require_user(request)
3027
+ return _tool_response(network_status)
3028
+
3029
+
3030
+ @app.post("/tools/build_project")
3031
+ async def tools_build_project(req: ToolScriptRequest, request: Request):
3032
+ require_user(request)
3033
+ return _tool_response(build_project, req.cwd, req.script)
3034
+
3035
+
3036
+ @app.post("/tools/deploy_project")
3037
+ async def tools_deploy_project(req: ToolScriptRequest, request: Request):
3038
+ require_user(request)
3039
+ return _tool_response(deploy_project, req.cwd, req.script)
3040
+
3041
+
3042
+ @app.get("/mcp/tools")
3043
+ async def mcp_tools():
3044
+ installed = load_mcp_installs().get("installed", {})
3045
+ return {
3046
+ "status": "ok",
3047
+ "workspace": str(AGENT_ROOT),
3048
+ "installed_mcps": [mcp_public_item(item, installed) for item in MCP_REGISTRY],
3049
+ "tools": [
3050
+ {"name": "list_dir", "description": "List files in the agent workspace."},
3051
+ {"name": "workspace_tree", "description": "Return a recursive workspace tree."},
3052
+ {"name": "read_file", "description": "Read a UTF-8 file from the workspace."},
3053
+ {"name": "write_file", "description": "Write a UTF-8 file inside the workspace."},
3054
+ {"name": "search_files", "description": "Search text files inside the workspace."},
3055
+ {"name": "clear_history", "description": "Clear chat history to reduce context and speed up responses."},
3056
+ {"name": "inspect_html", "description": "Inspect local HTML structure and assets."},
3057
+ {"name": "preview_url", "description": "Return a server URL for a workspace file."},
3058
+ {"name": "create_docx", "description": "Create a Word DOCX document in the agent workspace."},
3059
+ {"name": "create_xlsx", "description": "Create an XLSX spreadsheet in the agent workspace."},
3060
+ {"name": "create_pptx", "description": "Create a PPTX presentation deck in the agent workspace."},
3061
+ {"name": "create_pdf", "description": "Create a PDF document in the agent workspace."},
3062
+ {"name": "local_list", "description": "List any local folder (requires user permission via UI)."},
3063
+ {"name": "local_read", "description": "Read any local file (requires user permission via UI)."},
3064
+ {"name": "local_write", "description": "Write any local file (requires user permission via UI)."},
3065
+ {"name": "read_document", "description": "Extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV files."},
3066
+ {"name": "computer_screenshot", "description": "Capture the current Mac screen as base64 PNG."},
3067
+ {"name": "computer_open_app", "description": "Open or focus a Mac app, e.g. Google Chrome."},
3068
+ {"name": "computer_open_url", "description": "Open a URL in a Mac app, e.g. Google Chrome."},
3069
+ {"name": "computer_click", "description": "Click at screen coordinates (x, y)."},
3070
+ {"name": "computer_type", "description": "Type text at the current focus position."},
3071
+ {"name": "computer_key", "description": "Press a keyboard key or shortcut (e.g. 'command+c')."},
3072
+ {"name": "computer_scroll", "description": "Scroll at screen coordinates."},
3073
+ {"name": "computer_move", "description": "Move the mouse to screen coordinates."},
3074
+ {"name": "computer_drag", "description": "Drag from (x1,y1) to (x2,y2)."},
3075
+ {"name": "computer_status", "description": "Check if Mac Computer Use (pyautogui) is available."},
3076
+ {"name": "chrome_status", "description": "Report Chrome desktop bridge availability."},
3077
+ {"name": "computer_use_status", "description": "Report Mac Computer Use bridge availability."},
3078
+ {"name": "knowledge_save", "description": "Save a note into the local knowledge garden."},
3079
+ {"name": "knowledge_search", "description": "Search the local knowledge garden."},
3080
+ {"name": "knowledge_tree", "description": "List local knowledge garden markdown files."},
3081
+ {"name": "obsidian_save", "description": "Save a note into the Obsidian-compatible memory vault."},
3082
+ {"name": "obsidian_search", "description": "Search the Obsidian-compatible memory vault."},
3083
+ {"name": "obsidian_tree", "description": "List Obsidian memory vault markdown files."},
3084
+ {"name": "git_status", "description": "Read-only local git status inside the workspace."},
3085
+ {"name": "git_diff", "description": "Read-only local git diff inside the workspace."},
3086
+ {"name": "git_log", "description": "Read-only local git log inside the workspace."},
3087
+ {"name": "git_show", "description": "Read-only local git show --stat inside the workspace."},
3088
+ {"name": "network_status", "description": "Get current local/private IP, public IP, hostname, and Wi-Fi info."},
3089
+ {"name": "run_command", "description": "Run an allowlisted local command inside the workspace."},
3090
+ {"name": "build_project", "description": "Run an allowlisted package.json build/compile/typecheck/test script."},
3091
+ {"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release script."},
3092
+ ],
3093
+ }
3094
+
3095
+
3096
+ @app.post("/mcp/recommend")
3097
+ async def mcp_recommend(req: McpRecommendRequest, request: Request):
3098
+ require_user(request)
3099
+ return {"recommendations": recommend_mcps(req.query, req.limit)}
3100
+
3101
+
3102
+ @app.post("/mcp/install")
3103
+ async def mcp_install(req: McpInstallRequest, request: Request):
3104
+ require_user(request)
3105
+ return install_mcp(req.mcp_id)
3106
+
3107
+
3108
+ @app.get("/mcp/installed")
3109
+ async def mcp_installed(request: Request):
3110
+ require_user(request)
3111
+ installed = load_mcp_installs().get("installed", {})
3112
+ return {"installed": [mcp_public_item(item, installed) for item in MCP_REGISTRY]}
3113
+
3114
+
3115
+ @app.get("/mcp/connectors/{mcp_id}")
3116
+ async def mcp_connector(mcp_id: str, request: Request):
3117
+ require_user(request)
3118
+ return connector_info(mcp_id)
3119
+
3120
+
3121
+ @app.post("/mcp/call")
3122
+ async def mcp_call(req: McpCallRequest, request: Request):
3123
+ require_user(request)
3124
+ return _tool_response(execute_tool, req.action, req.args or {})
3125
+
3126
+
3127
+ # ── P-Reinforce Knowledge Gardener ────────────────────────────────────────────
3128
+
3129
+ @app.post("/garden")
3130
+ async def garden(req: GardenRequest, request: Request):
3131
+ """Raw 데이터를 P-Reinforce 구조로 자동 분류·저장"""
3132
+ require_user(request)
3133
+ result = await gardener.process(req.raw_data, req.category)
3134
+ return result
3135
+
3136
+
3137
+ @app.get("/garden/tree")
3138
+ async def garden_tree(request: Request):
3139
+ """지식 정원 파일트리 반환"""
3140
+ require_user(request)
3141
+ return gardener.get_tree()
3142
+
3143
+
3144
+ # ── Setup Wizard ─────────────────────────────────────────────────────────────
3145
+
3146
+ class SetupInstallRequest(BaseModel):
3147
+ items: List[Dict]
3148
+
3149
+ @app.get("/setup/scan")
3150
+ async def setup_scan(request: Request):
3151
+ """환경 감지 및 맞춤 추천 반환."""
3152
+ require_user(request)
3153
+ env = scan_environment()
3154
+ recs = get_recommendations(env)
3155
+ return {"environment": env, "recommendations": recs}
3156
+
3157
+ @app.post("/setup/install")
3158
+ async def setup_install(req: SetupInstallRequest, request: Request):
3159
+ """선택된 항목을 순서대로 설치 · 로드하는 SSE 스트림."""
3160
+ require_user(request)
3161
+ async def _gen():
3162
+ async for chunk in install_stream(req.items, router):
3163
+ yield chunk
3164
+ return StreamingResponse(_gen(), media_type="text/event-stream",
3165
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
3166
+
3167
+ @app.post("/setup/open-auth/{mcp_id}")
3168
+ async def setup_open_auth(mcp_id: str, request: Request):
3169
+ require_user(request)
3170
+ """MCP 인증 페이지를 브라우저에서 자동으로 엽니다."""
3171
+ auth_urls: Dict[str, str] = {
3172
+ "github": "https://github.com/apps",
3173
+ "google-drive": "https://chatgpt.com/connectors",
3174
+ "slack": "https://chatgpt.com/connectors",
3175
+ "chrome": "https://chatgpt.com/connectors",
3176
+ "computer-use": "https://chatgpt.com/connectors",
3177
+ "figma": "https://chatgpt.com/connectors",
3178
+ "notion": "https://chatgpt.com/connectors",
3179
+ "linear": "https://chatgpt.com/connectors",
3180
+ "gmail": "https://chatgpt.com/connectors",
3181
+ "google-calendar": "https://chatgpt.com/connectors",
3182
+ "outlook-email": "https://chatgpt.com/connectors",
3183
+ "outlook-calendar": "https://chatgpt.com/connectors",
3184
+ "teams": "https://chatgpt.com/connectors",
3185
+ "sharepoint": "https://chatgpt.com/connectors",
3186
+ "canva": "https://chatgpt.com/connectors",
3187
+ }
3188
+ url = auth_urls.get(mcp_id)
3189
+ if not url:
3190
+ raise HTTPException(status_code=404, detail=f"알 수 없는 MCP: {mcp_id}")
3191
+ open_url(url)
3192
+ return {"status": "ok", "opened": url, "mcp_id": mcp_id}
3193
+
3194
+
3195
+ @app.post("/permissions/open/{permission_id}")
3196
+ async def open_permission_settings(permission_id: str, request: Request):
3197
+ require_user(request)
3198
+ """macOS 권한 설정 화면을 엽니다."""
3199
+ urls = {
3200
+ "accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
3201
+ "automation": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
3202
+ "screen": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
3203
+ }
3204
+ url = urls.get(permission_id)
3205
+ if not url:
3206
+ raise HTTPException(status_code=404, detail="알 수 없는 권한 설정입니다.")
3207
+ open_url(url)
3208
+ return {"status": "ok", "opened": url, "permission": permission_id}
3209
+
3210
+
3211
+ # ── Entry Point ────────────────────────────────────────────────────────────────
3212
+
3213
+ if __name__ == "__main__":
3214
+ print(f"🧠 Lattice AI Server starting in {APP_MODE} mode on http://{DEFAULT_HOST}:{DEFAULT_PORT}")
3215
+ uvicorn.run(app, host=DEFAULT_HOST, port=DEFAULT_PORT, log_level="info")