ltcai 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -16
- package/auto_setup.py +605 -0
- package/docs/CHANGELOG.md +57 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/kg_schema.py +723 -0
- package/llm_router.py +4 -4
- package/mcp_registry.py +791 -0
- package/package.json +5 -1
- package/requirements.txt +1 -0
- package/server.py +738 -823
- package/static/account.html +5 -616
- package/static/admin.html +236 -1371
- package/static/chat.html +204 -7146
- package/static/graph.html +15 -1436
- package/static/lattice-reference.css +6557 -71
- package/static/scripts/account.js +230 -0
- package/static/scripts/admin.js +1198 -0
- package/static/scripts/chat.js +4634 -0
- package/static/scripts/graph.js +1059 -0
- package/static/sw.js +11 -1
package/server.py
CHANGED
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import logging
|
|
13
13
|
import os
|
|
14
14
|
import platform
|
|
15
|
+
import queue
|
|
15
16
|
import re
|
|
16
17
|
import secrets
|
|
17
18
|
import threading
|
|
@@ -46,6 +47,15 @@ from PIL import Image
|
|
|
46
47
|
|
|
47
48
|
from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, HF_MODELS_ROOT, ensure_mlx_runtime, hf_model_dir, parse_model_ref, mx, normalize_branding
|
|
48
49
|
from knowledge_graph import KnowledgeGraphStore
|
|
50
|
+
import mcp_registry
|
|
51
|
+
from mcp_registry import (
|
|
52
|
+
MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
|
|
53
|
+
_MARKETPLACE_RAW, _MARKETPLACE_API,
|
|
54
|
+
_fetch_remote_mcp_registry, _get_combined_registry,
|
|
55
|
+
_extract_skill_desc, _fetch_plugin_skills,
|
|
56
|
+
_fetch_skills_marketplace, _fetch_plugin_directory,
|
|
57
|
+
_OPEN_LICENSES, install_skill, SKILLS_DIR,
|
|
58
|
+
)
|
|
49
59
|
from p_reinforce import BRAIN_DIR, PReinforceGardener
|
|
50
60
|
from setup import get_recommendations, install_stream, open_url, scan_environment
|
|
51
61
|
from auto_setup import (
|
|
@@ -211,19 +221,24 @@ SSO_CLIENT_SECRET = env_value("OIDC_CLIENT_SECRET", "")
|
|
|
211
221
|
SSO_REDIRECT_URI = env_value("OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback")
|
|
212
222
|
SSO_PROVIDER_NAME = env_value("OIDC_PROVIDER_NAME", "SSO")
|
|
213
223
|
_sso_discovery_cache: Optional[Dict] = None
|
|
224
|
+
_sso_discovery_cache_url: str = ""
|
|
214
225
|
_sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
|
|
215
226
|
|
|
216
227
|
async def _get_sso_discovery() -> Optional[Dict]:
|
|
217
|
-
global _sso_discovery_cache
|
|
218
|
-
|
|
228
|
+
global _sso_discovery_cache, _sso_discovery_cache_url
|
|
229
|
+
settings = get_sso_settings()
|
|
230
|
+
discovery_url = settings.get("discovery_url", "")
|
|
231
|
+
if _sso_discovery_cache and _sso_discovery_cache_url == discovery_url:
|
|
219
232
|
return _sso_discovery_cache
|
|
220
|
-
if not
|
|
233
|
+
if not discovery_url:
|
|
221
234
|
return None
|
|
222
235
|
try:
|
|
223
236
|
import httpx as _httpx
|
|
224
237
|
async with _httpx.AsyncClient() as c:
|
|
225
|
-
r = await c.get(
|
|
238
|
+
r = await c.get(discovery_url, timeout=10)
|
|
239
|
+
r.raise_for_status()
|
|
226
240
|
_sso_discovery_cache = r.json()
|
|
241
|
+
_sso_discovery_cache_url = discovery_url
|
|
227
242
|
except Exception as e:
|
|
228
243
|
logging.warning("SSO discovery failed: %s", e)
|
|
229
244
|
return None
|
|
@@ -254,7 +269,7 @@ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict
|
|
|
254
269
|
append_audit_event("password_migrated_from_plaintext", user_email=email)
|
|
255
270
|
except Exception as e:
|
|
256
271
|
logging.warning("audit log failed on password migration: %s", e)
|
|
257
|
-
logging.info("Migrated plaintext password to
|
|
272
|
+
logging.info("Migrated plaintext password to scrypt hash for %s", email)
|
|
258
273
|
return True
|
|
259
274
|
return False
|
|
260
275
|
|
|
@@ -357,11 +372,12 @@ HISTORY_FILE = DATA_DIR / "chat_history.json"
|
|
|
357
372
|
VPC_FILE = DATA_DIR / "vpc_config.json"
|
|
358
373
|
MCP_FILE = DATA_DIR / "mcp_installs.json"
|
|
359
374
|
AUDIT_FILE = DATA_DIR / "audit_log.json"
|
|
375
|
+
SSO_FILE = DATA_DIR / "sso_config.json"
|
|
360
376
|
KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
|
|
361
377
|
|
|
362
378
|
def _require_graph():
|
|
363
379
|
if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
|
|
364
|
-
raise HTTPException(status_code=404, detail="
|
|
380
|
+
raise HTTPException(status_code=404, detail="지식 그래프가 비활성화되어 있습니다. LATTICEAI_ENABLE_GRAPH=true 설정 후 다시 시도해 주세요.")
|
|
365
381
|
|
|
366
382
|
class UserRegister(BaseModel):
|
|
367
383
|
email: str
|
|
@@ -387,6 +403,75 @@ class VpcConfigUpdate(BaseModel):
|
|
|
387
403
|
peering_status: Optional[str] = None
|
|
388
404
|
notes: Optional[str] = None
|
|
389
405
|
|
|
406
|
+
class SsoConfigUpdate(BaseModel):
|
|
407
|
+
enabled: Optional[bool] = None
|
|
408
|
+
provider_name: Optional[str] = None
|
|
409
|
+
discovery_url: Optional[str] = None
|
|
410
|
+
client_id: Optional[str] = None
|
|
411
|
+
client_secret: Optional[str] = None
|
|
412
|
+
redirect_uri: Optional[str] = None
|
|
413
|
+
scopes: Optional[str] = None
|
|
414
|
+
|
|
415
|
+
def _sso_env_defaults() -> Dict[str, object]:
|
|
416
|
+
return {
|
|
417
|
+
"enabled": bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET),
|
|
418
|
+
"provider_name": SSO_PROVIDER_NAME,
|
|
419
|
+
"discovery_url": SSO_DISCOVERY_URL,
|
|
420
|
+
"client_id": SSO_CLIENT_ID,
|
|
421
|
+
"client_secret": SSO_CLIENT_SECRET,
|
|
422
|
+
"redirect_uri": SSO_REDIRECT_URI,
|
|
423
|
+
"scopes": "openid email profile",
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
def load_sso_config() -> Dict[str, object]:
|
|
427
|
+
config = _sso_env_defaults()
|
|
428
|
+
if SSO_FILE.exists():
|
|
429
|
+
try:
|
|
430
|
+
data = json.loads(SSO_FILE.read_text(encoding="utf-8"))
|
|
431
|
+
if isinstance(data, dict):
|
|
432
|
+
config.update({k: v for k, v in data.items() if v is not None})
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logging.warning("load_sso_config failed (using env/defaults): %s", e)
|
|
435
|
+
config["provider_name"] = str(config.get("provider_name") or "SSO")
|
|
436
|
+
config["discovery_url"] = str(config.get("discovery_url") or "")
|
|
437
|
+
config["client_id"] = str(config.get("client_id") or "")
|
|
438
|
+
config["client_secret"] = str(config.get("client_secret") or "")
|
|
439
|
+
config["redirect_uri"] = str(config.get("redirect_uri") or SSO_REDIRECT_URI)
|
|
440
|
+
config["scopes"] = str(config.get("scopes") or "openid email profile")
|
|
441
|
+
config["enabled"] = bool(config.get("enabled")) and bool(
|
|
442
|
+
config["discovery_url"] and config["client_id"] and config["client_secret"]
|
|
443
|
+
)
|
|
444
|
+
return config
|
|
445
|
+
|
|
446
|
+
def get_sso_settings() -> Dict[str, object]:
|
|
447
|
+
return load_sso_config()
|
|
448
|
+
|
|
449
|
+
def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
|
|
450
|
+
cfg = config or get_sso_settings()
|
|
451
|
+
return {
|
|
452
|
+
"enabled": bool(cfg.get("enabled")),
|
|
453
|
+
"provider_name": cfg.get("provider_name") or "",
|
|
454
|
+
"discovery_url": cfg.get("discovery_url") or "",
|
|
455
|
+
"client_id": cfg.get("client_id") or "",
|
|
456
|
+
"redirect_uri": cfg.get("redirect_uri") or SSO_REDIRECT_URI,
|
|
457
|
+
"scopes": cfg.get("scopes") or "openid email profile",
|
|
458
|
+
"secret_configured": bool(cfg.get("client_secret")),
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
|
|
462
|
+
global _sso_discovery_cache, _sso_discovery_cache_url
|
|
463
|
+
current = load_sso_config()
|
|
464
|
+
if update.get("client_secret") == "":
|
|
465
|
+
update.pop("client_secret", None)
|
|
466
|
+
current.update({k: v for k, v in update.items() if v is not None})
|
|
467
|
+
current["enabled"] = bool(current.get("enabled")) and bool(
|
|
468
|
+
current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
|
|
469
|
+
)
|
|
470
|
+
SSO_FILE.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
471
|
+
_sso_discovery_cache = None
|
|
472
|
+
_sso_discovery_cache_url = ""
|
|
473
|
+
return current
|
|
474
|
+
|
|
390
475
|
class McpRecommendRequest(BaseModel):
|
|
391
476
|
query: str
|
|
392
477
|
limit: int = 5
|
|
@@ -429,782 +514,6 @@ DEFAULT_VPC_CONFIG = {
|
|
|
429
514
|
"updated_at": None,
|
|
430
515
|
}
|
|
431
516
|
|
|
432
|
-
MCP_REGISTRY = [
|
|
433
|
-
{
|
|
434
|
-
"id": "presentations",
|
|
435
|
-
"name": "Presentations MCP",
|
|
436
|
-
"category": "PPT / slides",
|
|
437
|
-
"install_mode": "bundled",
|
|
438
|
-
"description": "PowerPoint, Google Slides용 발표자료를 만들고 렌더링 검수까지 이어갑니다.",
|
|
439
|
-
"keywords": ["ppt", "powerpoint", "slides", "slide", "deck", "presentation", "발표", "피피티", "프레젠테이션", "슬라이드", "제안서"],
|
|
440
|
-
"capabilities": ["PPTX 생성", "슬라이드 구조화", "차트 중심 스토리", "렌더링 검수"],
|
|
441
|
-
},
|
|
442
|
-
{
|
|
443
|
-
"id": "documents",
|
|
444
|
-
"name": "Documents MCP",
|
|
445
|
-
"category": "Docs / reports",
|
|
446
|
-
"install_mode": "bundled",
|
|
447
|
-
"description": "Word 문서, 보고서, 계약서 초안, 문서 redline 및 시각 검수를 처리합니다.",
|
|
448
|
-
"keywords": ["docx", "word", "docs", "document", "report", "문서", "보고서", "계약서", "기획서", "레포트"],
|
|
449
|
-
"capabilities": ["DOCX 생성", "문서 편집", "코멘트/수정", "PDF 렌더 확인"],
|
|
450
|
-
},
|
|
451
|
-
{
|
|
452
|
-
"id": "spreadsheets",
|
|
453
|
-
"name": "Spreadsheets MCP",
|
|
454
|
-
"category": "Sheets / data",
|
|
455
|
-
"install_mode": "bundled",
|
|
456
|
-
"description": "Excel/CSV/Google Sheets형 데이터 분석, 수식, 표, 차트를 만듭니다.",
|
|
457
|
-
"keywords": ["xlsx", "excel", "spreadsheet", "sheet", "csv", "data", "엑셀", "스프레드시트", "표", "데이터", "차트"],
|
|
458
|
-
"capabilities": ["XLSX 생성", "수식/서식", "데이터 분석", "차트"],
|
|
459
|
-
},
|
|
460
|
-
{
|
|
461
|
-
"id": "browser",
|
|
462
|
-
"name": "Browser MCP",
|
|
463
|
-
"category": "Web / dashboard QA",
|
|
464
|
-
"install_mode": "bundled",
|
|
465
|
-
"description": "로컬 웹앱, 대시보드, 폼, 페이지 렌더링을 브라우저에서 확인합니다.",
|
|
466
|
-
"keywords": ["dashboard", "web", "website", "frontend", "ui", "browser", "localhost", "대시보드", "웹", "사이트", "프론트", "화면", "검수"],
|
|
467
|
-
"capabilities": ["로컬 페이지 열기", "스크린샷", "DOM 검사", "UI 회귀 확인"],
|
|
468
|
-
},
|
|
469
|
-
{
|
|
470
|
-
"id": "chrome",
|
|
471
|
-
"name": "Chrome MCP",
|
|
472
|
-
"category": "Browser / authenticated web",
|
|
473
|
-
"install_mode": "connector",
|
|
474
|
-
"connector_url": "/mcp/connectors/chrome",
|
|
475
|
-
"external_url": "codex://plugins/chrome",
|
|
476
|
-
"description": "사용자 Chrome 프로필, 로그인 세션, 기존 탭을 활용하는 브라우저 자동화 브리지입니다.",
|
|
477
|
-
"keywords": ["chrome", "browser", "cookie", "session", "login", "크롬", "브라우저", "로그인", "세션", "탭"],
|
|
478
|
-
"capabilities": ["Chrome 탭 확인", "로그인 세션 활용", "프로필 기반 웹 자동화"],
|
|
479
|
-
},
|
|
480
|
-
{
|
|
481
|
-
"id": "computer-use",
|
|
482
|
-
"name": "Computer Use MCP",
|
|
483
|
-
"category": "Desktop / Mac UI",
|
|
484
|
-
"install_mode": "connector",
|
|
485
|
-
"connector_url": "/mcp/connectors/computer-use",
|
|
486
|
-
"external_url": "codex://plugins/computer-use",
|
|
487
|
-
"description": "Mac 앱 화면을 읽고 클릭, 타이핑, 스크롤하는 데스크톱 UI 자동화 브리지입니다.",
|
|
488
|
-
"keywords": ["computer use", "desktop", "mac", "click", "type", "scroll", "컴퓨터", "맥", "앱", "클릭", "타이핑"],
|
|
489
|
-
"capabilities": ["Mac 앱 UI 조작", "스크린샷 기반 상태 확인", "클릭/입력/스크롤"],
|
|
490
|
-
},
|
|
491
|
-
{
|
|
492
|
-
"id": "filesystem",
|
|
493
|
-
"name": "Workspace Files MCP",
|
|
494
|
-
"category": "Files / coding",
|
|
495
|
-
"install_mode": "builtin",
|
|
496
|
-
"description": "프로젝트 파일 읽기/쓰기, 검색, 코드 생성, 로컬 preview URL 생성을 수행합니다.",
|
|
497
|
-
"keywords": ["code", "coding", "file", "folder", "project", "build", "deploy", "구현", "코드", "파일", "폴더", "프로젝트", "빌드", "배포"],
|
|
498
|
-
"capabilities": ["파일 생성", "코드 검색", "빌드 스크립트", "배포 스크립트"],
|
|
499
|
-
},
|
|
500
|
-
{
|
|
501
|
-
"id": "google-drive",
|
|
502
|
-
"name": "Google Drive Connector",
|
|
503
|
-
"category": "File sharing",
|
|
504
|
-
"install_mode": "connector",
|
|
505
|
-
"connector_url": "/mcp/connectors/google-drive",
|
|
506
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
507
|
-
"description": "Drive/Docs/Sheets/Slides 파일 공유, 검색, 협업 워크플로에 사용합니다.",
|
|
508
|
-
"keywords": ["share", "sharing", "drive", "google drive", "file share", "공유", "파일공유", "드라이브", "구글드라이브", "협업"],
|
|
509
|
-
"capabilities": ["파일 공유", "Drive 검색", "Google Docs/Sheets/Slides 연결"],
|
|
510
|
-
},
|
|
511
|
-
{
|
|
512
|
-
"id": "github",
|
|
513
|
-
"name": "GitHub Connector",
|
|
514
|
-
"category": "Code hosting",
|
|
515
|
-
"install_mode": "connector",
|
|
516
|
-
"connector_url": "/mcp/connectors/github",
|
|
517
|
-
"external_url": "https://github.com/apps",
|
|
518
|
-
"description": "저장소, 이슈, PR, CI 확인과 코드 배포 워크플로를 연결합니다.",
|
|
519
|
-
"keywords": ["github", "repo", "repository", "pr", "pull request", "issue", "ci", "깃허브", "저장소", "이슈", "배포"],
|
|
520
|
-
"capabilities": ["PR 확인", "이슈 탐색", "CI 확인", "릴리즈 준비"],
|
|
521
|
-
},
|
|
522
|
-
{
|
|
523
|
-
"id": "slack",
|
|
524
|
-
"name": "Slack Connector",
|
|
525
|
-
"category": "Team sharing",
|
|
526
|
-
"install_mode": "connector",
|
|
527
|
-
"connector_url": "/mcp/connectors/slack",
|
|
528
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
529
|
-
"description": "팀 채널에 결과 공유, 논의 요약, 알림 워크플로를 연결합니다.",
|
|
530
|
-
"keywords": ["slack", "message", "team", "notify", "공유", "알림", "메시지", "슬랙", "팀"],
|
|
531
|
-
"capabilities": ["채널 공유", "메시지 작성", "협업 알림"],
|
|
532
|
-
},
|
|
533
|
-
{
|
|
534
|
-
"id": "obsidian-memory",
|
|
535
|
-
"name": "Obsidian Memory Vault",
|
|
536
|
-
"category": "Memory / knowledge",
|
|
537
|
-
"install_mode": "builtin",
|
|
538
|
-
"description": "Lattice AI의 장기 기억을 Obsidian 호환 Markdown vault에 저장하고 검색합니다.",
|
|
539
|
-
"keywords": ["memory", "remember", "obsidian", "vault", "knowledge", "기억", "메모리", "옵시디언", "지식", "노트"],
|
|
540
|
-
"capabilities": ["Markdown vault 저장", "장기 기억 검색", "Obsidian URI 힌트", "프로젝트 로그"],
|
|
541
|
-
},
|
|
542
|
-
{
|
|
543
|
-
"id": "voice-whisper",
|
|
544
|
-
"name": "Voice STT (Whisper Local)",
|
|
545
|
-
"category": "Voice / speech-to-text",
|
|
546
|
-
"install_mode": "pip",
|
|
547
|
-
"pip_packages": ["openai-whisper"],
|
|
548
|
-
"description": "로컬 음성 인식(STT) 파이프라인용 Whisper 런타임을 설치합니다.",
|
|
549
|
-
"keywords": ["voice", "speech", "stt", "whisper", "audio", "음성", "인식", "자막", "전사"],
|
|
550
|
-
"capabilities": ["로컬 STT 런타임", "오디오 전사 워크플로 준비"],
|
|
551
|
-
},
|
|
552
|
-
{
|
|
553
|
-
"id": "voice-speechrecognition",
|
|
554
|
-
"name": "Voice STT (SpeechRecognition)",
|
|
555
|
-
"category": "Voice / speech-to-text",
|
|
556
|
-
"install_mode": "pip",
|
|
557
|
-
"pip_packages": ["SpeechRecognition"],
|
|
558
|
-
"description": "가벼운 음성 인식 실험용 SpeechRecognition 패키지를 설치합니다.",
|
|
559
|
-
"keywords": ["voice", "speech", "recognition", "stt", "microphone", "음성", "마이크", "받아쓰기"],
|
|
560
|
-
"capabilities": ["STT 파이썬 패키지", "마이크 입력 인식 실험"],
|
|
561
|
-
},
|
|
562
|
-
{
|
|
563
|
-
"id": "audio-pydub",
|
|
564
|
-
"name": "Audio Processing (PyDub)",
|
|
565
|
-
"category": "Voice / audio processing",
|
|
566
|
-
"install_mode": "pip",
|
|
567
|
-
"pip_packages": ["pydub"],
|
|
568
|
-
"description": "오디오 파일 분할/정규화/포맷 변환 워크플로용 패키지를 설치합니다.",
|
|
569
|
-
"keywords": ["audio", "pydub", "wav", "mp3", "전처리", "오디오", "변환"],
|
|
570
|
-
"capabilities": ["오디오 전처리", "세그먼트 분할", "포맷 변환"],
|
|
571
|
-
},
|
|
572
|
-
{
|
|
573
|
-
"id": "threejs-workflow",
|
|
574
|
-
"name": "3D Workflow (Three.js)",
|
|
575
|
-
"category": "3D / interactive web",
|
|
576
|
-
"install_mode": "bundled",
|
|
577
|
-
"description": "브라우저 검수 + 코드 생성 흐름으로 Three.js 기반 3D 화면을 구현/검증합니다.",
|
|
578
|
-
"keywords": ["3d", "three", "threejs", "webgl", "scene", "3차원", "쓰리제이에스", "렌더링"],
|
|
579
|
-
"capabilities": ["Three.js 코드 생성", "3D 씬 검수", "브라우저 상호작용 테스트"],
|
|
580
|
-
},
|
|
581
|
-
{
|
|
582
|
-
"id": "figma",
|
|
583
|
-
"name": "Figma Connector",
|
|
584
|
-
"category": "Design / handoff",
|
|
585
|
-
"install_mode": "connector",
|
|
586
|
-
"connector_url": "/mcp/connectors/figma",
|
|
587
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
588
|
-
"description": "디자인 파일 참조, 컴포넌트 규칙 확인, 구현 핸드오프를 연결합니다.",
|
|
589
|
-
"keywords": ["figma", "design", "handoff", "컴포넌트", "디자인", "피그마"],
|
|
590
|
-
"capabilities": ["디자인 참조", "핸드오프 워크플로", "컴포넌트 맵핑"],
|
|
591
|
-
},
|
|
592
|
-
{
|
|
593
|
-
"id": "notion",
|
|
594
|
-
"name": "Notion Connector",
|
|
595
|
-
"category": "Knowledge / docs",
|
|
596
|
-
"install_mode": "connector",
|
|
597
|
-
"connector_url": "/mcp/connectors/notion",
|
|
598
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
599
|
-
"description": "노션 문서/DB와 연동해 구현 노트, 회의 요약, 지식 관리 워크플로를 만듭니다.",
|
|
600
|
-
"keywords": ["notion", "wiki", "docs", "database", "노션", "위키", "문서", "지식관리"],
|
|
601
|
-
"capabilities": ["페이지 검색", "문서 작성 보조", "지식 동기화"],
|
|
602
|
-
},
|
|
603
|
-
{
|
|
604
|
-
"id": "linear",
|
|
605
|
-
"name": "Linear Connector",
|
|
606
|
-
"category": "Project / issue tracking",
|
|
607
|
-
"install_mode": "connector",
|
|
608
|
-
"connector_url": "/mcp/connectors/linear",
|
|
609
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
610
|
-
"description": "이슈 상태 확인, 우선순위 정리, 릴리즈 태스크 연결에 사용합니다.",
|
|
611
|
-
"keywords": ["linear", "issue", "project", "sprint", "이슈", "태스크", "프로젝트"],
|
|
612
|
-
"capabilities": ["이슈 조회", "작업 우선순위", "릴리즈 트래킹"],
|
|
613
|
-
},
|
|
614
|
-
{
|
|
615
|
-
"id": "gmail",
|
|
616
|
-
"name": "Gmail Connector",
|
|
617
|
-
"category": "Communication / email",
|
|
618
|
-
"install_mode": "connector",
|
|
619
|
-
"connector_url": "/mcp/connectors/gmail",
|
|
620
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
621
|
-
"description": "이메일 요약, 답장 초안, 업무 메일 정리에 사용합니다.",
|
|
622
|
-
"keywords": ["gmail", "email", "mail", "inbox", "메일", "지메일", "이메일"],
|
|
623
|
-
"capabilities": ["메일 검색", "요약", "답장 초안"],
|
|
624
|
-
},
|
|
625
|
-
{
|
|
626
|
-
"id": "google-calendar",
|
|
627
|
-
"name": "Google Calendar Connector",
|
|
628
|
-
"category": "Scheduling / calendar",
|
|
629
|
-
"install_mode": "connector",
|
|
630
|
-
"connector_url": "/mcp/connectors/google-calendar",
|
|
631
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
632
|
-
"description": "일정 확인, 미팅 슬롯 탐색, 일정 생성 워크플로를 연결합니다.",
|
|
633
|
-
"keywords": ["calendar", "schedule", "meeting", "구글캘린더", "일정", "미팅"],
|
|
634
|
-
"capabilities": ["일정 조회", "빈 시간 탐색", "이벤트 생성"],
|
|
635
|
-
},
|
|
636
|
-
{
|
|
637
|
-
"id": "outlook-email",
|
|
638
|
-
"name": "Outlook Email Connector",
|
|
639
|
-
"category": "Communication / email",
|
|
640
|
-
"install_mode": "connector",
|
|
641
|
-
"connector_url": "/mcp/connectors/outlook-email",
|
|
642
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
643
|
-
"description": "Outlook 메일함 연동, 메일 검색/초안/요약 워크플로를 제공합니다.",
|
|
644
|
-
"keywords": ["outlook", "email", "mail", "아웃룩", "메일"],
|
|
645
|
-
"capabilities": ["메일 검색", "요약", "초안 작성"],
|
|
646
|
-
},
|
|
647
|
-
{
|
|
648
|
-
"id": "outlook-calendar",
|
|
649
|
-
"name": "Outlook Calendar Connector",
|
|
650
|
-
"category": "Scheduling / calendar",
|
|
651
|
-
"install_mode": "connector",
|
|
652
|
-
"connector_url": "/mcp/connectors/outlook-calendar",
|
|
653
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
654
|
-
"description": "Outlook 일정 연동으로 회의 준비/시간 조율 작업을 진행합니다.",
|
|
655
|
-
"keywords": ["outlook calendar", "calendar", "schedule", "아웃룩 캘린더", "일정"],
|
|
656
|
-
"capabilities": ["일정 조회", "회의 준비", "시간 조율"],
|
|
657
|
-
},
|
|
658
|
-
{
|
|
659
|
-
"id": "teams",
|
|
660
|
-
"name": "Microsoft Teams Connector",
|
|
661
|
-
"category": "Team collaboration",
|
|
662
|
-
"install_mode": "connector",
|
|
663
|
-
"connector_url": "/mcp/connectors/teams",
|
|
664
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
665
|
-
"description": "팀 대화 컨텍스트 기반 업무 자동화와 협업 공유를 지원합니다.",
|
|
666
|
-
"keywords": ["teams", "microsoft teams", "chat", "협업", "팀즈"],
|
|
667
|
-
"capabilities": ["팀 대화 공유", "협업 흐름 연결"],
|
|
668
|
-
},
|
|
669
|
-
{
|
|
670
|
-
"id": "sharepoint",
|
|
671
|
-
"name": "SharePoint Connector",
|
|
672
|
-
"category": "Enterprise files",
|
|
673
|
-
"install_mode": "connector",
|
|
674
|
-
"connector_url": "/mcp/connectors/sharepoint",
|
|
675
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
676
|
-
"description": "SharePoint 문서 저장소를 검색/참조하는 엔터프라이즈 워크플로를 지원합니다.",
|
|
677
|
-
"keywords": ["sharepoint", "document", "enterprise", "문서", "셰어포인트"],
|
|
678
|
-
"capabilities": ["문서 검색", "사내 파일 참조"],
|
|
679
|
-
},
|
|
680
|
-
{
|
|
681
|
-
"id": "canva",
|
|
682
|
-
"name": "Canva Connector",
|
|
683
|
-
"category": "Design / visuals",
|
|
684
|
-
"install_mode": "connector",
|
|
685
|
-
"connector_url": "/mcp/connectors/canva",
|
|
686
|
-
"external_url": "https://chatgpt.com/connectors",
|
|
687
|
-
"description": "디자인 템플릿 기반 이미지/슬라이드 작업을 연동합니다.",
|
|
688
|
-
"keywords": ["canva", "design", "poster", "card", "캔바", "디자인"],
|
|
689
|
-
"capabilities": ["디자인 템플릿", "이미지 제작 워크플로"],
|
|
690
|
-
},
|
|
691
|
-
# ── 데이터베이스 ─────────────────────────────────────────────────────────
|
|
692
|
-
{
|
|
693
|
-
"id": "mcp-postgres",
|
|
694
|
-
"name": "PostgreSQL MCP",
|
|
695
|
-
"category": "Database",
|
|
696
|
-
"install_mode": "npm",
|
|
697
|
-
"package": "@modelcontextprotocol/server-postgres",
|
|
698
|
-
"description": "PostgreSQL 데이터베이스에 연결해 쿼리 실행, 스키마 탐색, 데이터 분석을 수행합니다.",
|
|
699
|
-
"keywords": ["postgres", "postgresql", "database", "sql", "db", "데이터베이스", "쿼리"],
|
|
700
|
-
"capabilities": ["SQL 쿼리 실행", "스키마 탐색", "테이블 분석"],
|
|
701
|
-
"env_vars": [{"name": "POSTGRES_CONNECTION_STRING", "description": "postgresql://user:pass@host:5432/db"}],
|
|
702
|
-
},
|
|
703
|
-
{
|
|
704
|
-
"id": "mcp-sqlite",
|
|
705
|
-
"name": "SQLite MCP",
|
|
706
|
-
"category": "Database",
|
|
707
|
-
"install_mode": "npm",
|
|
708
|
-
"package": "@modelcontextprotocol/server-sqlite",
|
|
709
|
-
"description": "로컬 SQLite 파일에 쿼리를 실행하고 데이터를 탐색합니다.",
|
|
710
|
-
"keywords": ["sqlite", "database", "sql", "local", "로컬", "데이터베이스"],
|
|
711
|
-
"capabilities": ["SQLite 쿼리", "테이블 탐색", "데이터 집계"],
|
|
712
|
-
"env_vars": [{"name": "SQLITE_DB_PATH", "description": "/path/to/database.db"}],
|
|
713
|
-
},
|
|
714
|
-
# ── 검색 / 웹 ────────────────────────────────────────────────────────────
|
|
715
|
-
{
|
|
716
|
-
"id": "mcp-brave-search",
|
|
717
|
-
"name": "Brave Search MCP",
|
|
718
|
-
"category": "Search / web",
|
|
719
|
-
"install_mode": "npm",
|
|
720
|
-
"package": "@modelcontextprotocol/server-brave-search",
|
|
721
|
-
"description": "Brave Search API로 실시간 웹 검색 결과를 가져옵니다.",
|
|
722
|
-
"keywords": ["search", "web", "brave", "websearch", "검색", "웹검색"],
|
|
723
|
-
"capabilities": ["실시간 웹 검색", "뉴스 검색", "이미지 검색"],
|
|
724
|
-
"env_vars": [{"name": "BRAVE_API_KEY", "description": "Brave Search API 키 (search.brave.com)"}],
|
|
725
|
-
},
|
|
726
|
-
{
|
|
727
|
-
"id": "mcp-tavily",
|
|
728
|
-
"name": "Tavily Search MCP",
|
|
729
|
-
"category": "Search / web",
|
|
730
|
-
"install_mode": "npm",
|
|
731
|
-
"package": "tavily-mcp",
|
|
732
|
-
"description": "AI 최적화 웹 검색 엔진 Tavily로 고품질 검색 결과를 가져옵니다.",
|
|
733
|
-
"keywords": ["search", "tavily", "ai search", "검색", "AI검색"],
|
|
734
|
-
"capabilities": ["AI 최적화 검색", "요약 검색 결과"],
|
|
735
|
-
"env_vars": [{"name": "TAVILY_API_KEY", "description": "app.tavily.com에서 발급"}],
|
|
736
|
-
},
|
|
737
|
-
{
|
|
738
|
-
"id": "mcp-puppeteer",
|
|
739
|
-
"name": "Puppeteer MCP",
|
|
740
|
-
"category": "Browser automation",
|
|
741
|
-
"install_mode": "npm",
|
|
742
|
-
"package": "@modelcontextprotocol/server-puppeteer",
|
|
743
|
-
"description": "Puppeteer로 브라우저를 제어하고 웹 스크래핑, 스크린샷, 자동화를 수행합니다.",
|
|
744
|
-
"keywords": ["puppeteer", "browser", "scraping", "screenshot", "automation", "스크래핑", "자동화"],
|
|
745
|
-
"capabilities": ["웹 스크래핑", "스크린샷", "폼 자동화", "클릭/입력"],
|
|
746
|
-
},
|
|
747
|
-
# ── 배포 / 인프라 ─────────────────────────────────────────────────────────
|
|
748
|
-
{
|
|
749
|
-
"id": "mcp-vercel",
|
|
750
|
-
"name": "Vercel MCP",
|
|
751
|
-
"category": "Deployment",
|
|
752
|
-
"install_mode": "npm",
|
|
753
|
-
"package": "@vercel/mcp-adapter",
|
|
754
|
-
"description": "Vercel 프로젝트 배포 상태 확인, 로그 조회, 환경 변수 관리를 수행합니다.",
|
|
755
|
-
"keywords": ["vercel", "deploy", "deployment", "serverless", "배포", "버셀"],
|
|
756
|
-
"capabilities": ["배포 상태 확인", "로그 조회", "환경 변수 관리"],
|
|
757
|
-
"env_vars": [{"name": "VERCEL_API_TOKEN", "description": "Vercel 계정 토큰"}],
|
|
758
|
-
},
|
|
759
|
-
{
|
|
760
|
-
"id": "mcp-cloudflare",
|
|
761
|
-
"name": "Cloudflare MCP",
|
|
762
|
-
"category": "Deployment / CDN",
|
|
763
|
-
"install_mode": "npm",
|
|
764
|
-
"package": "@cloudflare/mcp-server-cloudflare",
|
|
765
|
-
"description": "Cloudflare Workers, KV, R2, D1 등 Cloudflare 서비스를 관리합니다.",
|
|
766
|
-
"keywords": ["cloudflare", "workers", "cdn", "kv", "r2", "클라우드플레어"],
|
|
767
|
-
"capabilities": ["Workers 배포", "KV/R2 관리", "DNS 조회", "D1 쿼리"],
|
|
768
|
-
"env_vars": [{"name": "CLOUDFLARE_API_TOKEN", "description": "Cloudflare API 토큰"}],
|
|
769
|
-
},
|
|
770
|
-
{
|
|
771
|
-
"id": "mcp-docker",
|
|
772
|
-
"name": "Docker MCP",
|
|
773
|
-
"category": "Infrastructure",
|
|
774
|
-
"install_mode": "npm",
|
|
775
|
-
"package": "docker-mcp",
|
|
776
|
-
"description": "Docker 컨테이너 목록 조회, 실행/중지, 로그 확인을 수행합니다.",
|
|
777
|
-
"keywords": ["docker", "container", "devops", "도커", "컨테이너", "인프라"],
|
|
778
|
-
"capabilities": ["컨테이너 관리", "이미지 조회", "로그 확인", "실행/중지"],
|
|
779
|
-
},
|
|
780
|
-
# ── SaaS / 결제 ───────────────────────────────────────────────────────────
|
|
781
|
-
{
|
|
782
|
-
"id": "mcp-stripe",
|
|
783
|
-
"name": "Stripe MCP",
|
|
784
|
-
"category": "Payments",
|
|
785
|
-
"install_mode": "npm",
|
|
786
|
-
"package": "@stripe/agent-toolkit",
|
|
787
|
-
"description": "Stripe 결제, 고객, 구독, 인보이스를 조회하고 관리합니다.",
|
|
788
|
-
"keywords": ["stripe", "payment", "billing", "subscription", "결제", "스트라이프"],
|
|
789
|
-
"capabilities": ["결제 조회", "고객 관리", "구독 확인", "인보이스"],
|
|
790
|
-
"env_vars": [{"name": "STRIPE_SECRET_KEY", "description": "Stripe Secret Key (sk_...)"}],
|
|
791
|
-
},
|
|
792
|
-
{
|
|
793
|
-
"id": "mcp-supabase",
|
|
794
|
-
"name": "Supabase MCP",
|
|
795
|
-
"category": "Database / BaaS",
|
|
796
|
-
"install_mode": "npm",
|
|
797
|
-
"package": "@supabase/mcp-server-supabase",
|
|
798
|
-
"description": "Supabase 프로젝트의 DB 쿼리, Auth 관리, Storage 파일 접근을 수행합니다.",
|
|
799
|
-
"keywords": ["supabase", "database", "auth", "storage", "supabase", "슈퍼베이스"],
|
|
800
|
-
"capabilities": ["DB 쿼리", "Auth 사용자 조회", "Storage 파일 관리"],
|
|
801
|
-
"env_vars": [
|
|
802
|
-
{"name": "SUPABASE_URL", "description": "https://xxx.supabase.co"},
|
|
803
|
-
{"name": "SUPABASE_SERVICE_ROLE_KEY", "description": "service_role 키"},
|
|
804
|
-
],
|
|
805
|
-
},
|
|
806
|
-
{
|
|
807
|
-
"id": "mcp-hubspot",
|
|
808
|
-
"name": "HubSpot MCP",
|
|
809
|
-
"category": "CRM / marketing",
|
|
810
|
-
"install_mode": "npm",
|
|
811
|
-
"package": "@hubspot/mcp-server",
|
|
812
|
-
"description": "HubSpot CRM의 연락처, 딜, 캠페인 데이터를 조회하고 분석합니다.",
|
|
813
|
-
"keywords": ["hubspot", "crm", "marketing", "sales", "허브스팟", "CRM"],
|
|
814
|
-
"capabilities": ["연락처 조회", "딜 파이프라인", "캠페인 분석"],
|
|
815
|
-
"env_vars": [{"name": "HUBSPOT_ACCESS_TOKEN", "description": "HubSpot Private App 토큰"}],
|
|
816
|
-
},
|
|
817
|
-
# ── AI / 메모리 ───────────────────────────────────────────────────────────
|
|
818
|
-
{
|
|
819
|
-
"id": "mcp-memory",
|
|
820
|
-
"name": "Memory MCP (공식)",
|
|
821
|
-
"category": "Memory / knowledge",
|
|
822
|
-
"install_mode": "npm",
|
|
823
|
-
"package": "@modelcontextprotocol/server-memory",
|
|
824
|
-
"description": "대화 간 지속 메모리를 저장하고 검색하는 공식 MCP 서버입니다.",
|
|
825
|
-
"keywords": ["memory", "remember", "knowledge", "기억", "메모리", "지식"],
|
|
826
|
-
"capabilities": ["장기 기억 저장", "메모리 검색", "엔티티 추적"],
|
|
827
|
-
},
|
|
828
|
-
{
|
|
829
|
-
"id": "mcp-sequential-thinking",
|
|
830
|
-
"name": "Sequential Thinking MCP",
|
|
831
|
-
"category": "AI / reasoning",
|
|
832
|
-
"install_mode": "npm",
|
|
833
|
-
"package": "@modelcontextprotocol/server-sequential-thinking",
|
|
834
|
-
"description": "복잡한 문제를 단계별로 분해해 추론하는 사고 흐름 도구입니다.",
|
|
835
|
-
"keywords": ["reasoning", "thinking", "chain of thought", "추론", "사고"],
|
|
836
|
-
"capabilities": ["단계별 추론", "문제 분해", "사고 흐름 추적"],
|
|
837
|
-
},
|
|
838
|
-
# ── 커뮤니케이션 ──────────────────────────────────────────────────────────
|
|
839
|
-
{
|
|
840
|
-
"id": "mcp-discord",
|
|
841
|
-
"name": "Discord MCP",
|
|
842
|
-
"category": "Communication",
|
|
843
|
-
"install_mode": "npm",
|
|
844
|
-
"package": "discord-mcp",
|
|
845
|
-
"description": "Discord 서버 채널 메시지 전송, 읽기, 관리 자동화를 수행합니다.",
|
|
846
|
-
"keywords": ["discord", "message", "channel", "디스코드", "메시지", "알림"],
|
|
847
|
-
"capabilities": ["메시지 전송", "채널 읽기", "알림 자동화"],
|
|
848
|
-
"env_vars": [{"name": "DISCORD_BOT_TOKEN", "description": "Discord Bot 토큰"}],
|
|
849
|
-
},
|
|
850
|
-
{
|
|
851
|
-
"id": "mcp-telegram",
|
|
852
|
-
"name": "Telegram MCP",
|
|
853
|
-
"category": "Communication",
|
|
854
|
-
"install_mode": "npm",
|
|
855
|
-
"package": "telegram-mcp",
|
|
856
|
-
"description": "Telegram 봇을 통한 메시지 전송, 수신, 알림 자동화를 수행합니다.",
|
|
857
|
-
"keywords": ["telegram", "bot", "message", "텔레그램", "봇", "메시지"],
|
|
858
|
-
"capabilities": ["메시지 전송/수신", "알림 자동화", "그룹 관리"],
|
|
859
|
-
"env_vars": [{"name": "TELEGRAM_BOT_TOKEN", "description": "BotFather에서 발급한 토큰"}],
|
|
860
|
-
},
|
|
861
|
-
# ── 개발 도구 ─────────────────────────────────────────────────────────────
|
|
862
|
-
{
|
|
863
|
-
"id": "mcp-everything",
|
|
864
|
-
"name": "Everything MCP (테스트)",
|
|
865
|
-
"category": "Developer tools",
|
|
866
|
-
"install_mode": "npm",
|
|
867
|
-
"package": "@modelcontextprotocol/server-everything",
|
|
868
|
-
"description": "MCP 연결 테스트용 모든 기능이 포함된 데모 서버입니다.",
|
|
869
|
-
"keywords": ["test", "demo", "everything", "테스트", "개발"],
|
|
870
|
-
"capabilities": ["MCP 기능 테스트", "프로토타입"],
|
|
871
|
-
},
|
|
872
|
-
]
|
|
873
|
-
|
|
874
|
-
# ── Remote MCP Registry (registry.modelcontextprotocol.io) ───────────────────
|
|
875
|
-
_REMOTE_REGISTRY_CACHE: List[Dict] = []
|
|
876
|
-
_REMOTE_REGISTRY_FETCHED_AT: Optional[datetime] = None
|
|
877
|
-
_REMOTE_REGISTRY_TTL = timedelta(hours=1)
|
|
878
|
-
_REMOTE_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers"
|
|
879
|
-
_LOCAL_IDS = {e["id"] for e in MCP_REGISTRY}
|
|
880
|
-
|
|
881
|
-
async def _fetch_remote_mcp_registry() -> List[Dict]:
|
|
882
|
-
global _REMOTE_REGISTRY_CACHE, _REMOTE_REGISTRY_FETCHED_AT
|
|
883
|
-
now = datetime.now()
|
|
884
|
-
if _REMOTE_REGISTRY_FETCHED_AT and (now - _REMOTE_REGISTRY_FETCHED_AT) < _REMOTE_REGISTRY_TTL:
|
|
885
|
-
return _REMOTE_REGISTRY_CACHE
|
|
886
|
-
try:
|
|
887
|
-
result: List[Dict] = []
|
|
888
|
-
cursor = None
|
|
889
|
-
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
890
|
-
while True:
|
|
891
|
-
params: Dict = {"limit": 100}
|
|
892
|
-
if cursor:
|
|
893
|
-
params["cursor"] = cursor
|
|
894
|
-
resp = await client.get(_REMOTE_REGISTRY_URL, params=params)
|
|
895
|
-
resp.raise_for_status()
|
|
896
|
-
data = resp.json()
|
|
897
|
-
for s in data.get("servers", []):
|
|
898
|
-
srv = s["server"]
|
|
899
|
-
meta = s.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
|
|
900
|
-
if not meta.get("isLatest", True):
|
|
901
|
-
continue
|
|
902
|
-
pkg = next(
|
|
903
|
-
(p for p in srv.get("packages", [])
|
|
904
|
-
if p.get("transport", {}).get("type") == "stdio"
|
|
905
|
-
and p.get("registryType") in ("npm", "pypi")),
|
|
906
|
-
None,
|
|
907
|
-
)
|
|
908
|
-
if not pkg:
|
|
909
|
-
continue
|
|
910
|
-
entry_id = srv["name"].replace("/", "-").replace(".", "-")
|
|
911
|
-
if entry_id in _LOCAL_IDS:
|
|
912
|
-
continue
|
|
913
|
-
result.append({
|
|
914
|
-
"id": entry_id,
|
|
915
|
-
"name": srv.get("title") or srv["name"],
|
|
916
|
-
"category": "MCP Registry",
|
|
917
|
-
"install_mode": pkg["registryType"],
|
|
918
|
-
"package": pkg["identifier"],
|
|
919
|
-
"package_version": pkg.get("version"),
|
|
920
|
-
"description": srv.get("description", ""),
|
|
921
|
-
"keywords": [],
|
|
922
|
-
"capabilities": [],
|
|
923
|
-
"source": "registry",
|
|
924
|
-
"homepage": (srv.get("repository") or {}).get("url"),
|
|
925
|
-
})
|
|
926
|
-
cursor = data.get("nextCursor")
|
|
927
|
-
if not cursor:
|
|
928
|
-
break
|
|
929
|
-
_REMOTE_REGISTRY_CACHE = result
|
|
930
|
-
_REMOTE_REGISTRY_FETCHED_AT = now
|
|
931
|
-
logging.info("Fetched %d stdio MCP servers from remote registry", len(result))
|
|
932
|
-
except Exception as e:
|
|
933
|
-
logging.warning("Failed to fetch remote MCP registry: %s", e)
|
|
934
|
-
return _REMOTE_REGISTRY_CACHE
|
|
935
|
-
|
|
936
|
-
async def _get_combined_registry() -> List[Dict]:
|
|
937
|
-
remote = await _fetch_remote_mcp_registry()
|
|
938
|
-
return MCP_REGISTRY + remote
|
|
939
|
-
|
|
940
|
-
# ── Anthropic Skills Marketplace (Apache 2.0) ─────────────────────────────────
|
|
941
|
-
_MARKETPLACE_RAW = "https://raw.githubusercontent.com/anthropics/claude-plugins-official/main"
|
|
942
|
-
_MARKETPLACE_API = "https://api.github.com/repos/anthropics/claude-plugins-official/contents"
|
|
943
|
-
|
|
944
|
-
# 검증된 서드파티 skills 소스 (Apache-2.0 / MIT)
|
|
945
|
-
_THIRD_PARTY_SKILL_SOURCES: List[Dict] = [
|
|
946
|
-
{
|
|
947
|
-
"plugin": "adobe-for-creativity", "author": "Adobe", "license": "Apache-2.0",
|
|
948
|
-
"repo": "adobe/skills", "branch": "main",
|
|
949
|
-
"plugin_path": "plugins/creative-cloud/adobe-for-creativity",
|
|
950
|
-
"category": "design",
|
|
951
|
-
},
|
|
952
|
-
{
|
|
953
|
-
"plugin": "airtable", "author": "Airtable", "license": "MIT",
|
|
954
|
-
"repo": "Airtable/skills", "branch": "main",
|
|
955
|
-
"plugin_path": "plugins/airtable",
|
|
956
|
-
"category": "productivity",
|
|
957
|
-
},
|
|
958
|
-
{
|
|
959
|
-
"plugin": "auth0", "author": "Auth0", "license": "Apache-2.0",
|
|
960
|
-
"repo": "auth0/agent-skills", "branch": "main",
|
|
961
|
-
"plugin_path": "plugins/auth0",
|
|
962
|
-
"category": "security",
|
|
963
|
-
},
|
|
964
|
-
{
|
|
965
|
-
"plugin": "expo", "author": "Expo", "license": "MIT",
|
|
966
|
-
"repo": "expo/skills", "branch": "main",
|
|
967
|
-
"plugin_path": "plugins/expo",
|
|
968
|
-
"category": "development",
|
|
969
|
-
},
|
|
970
|
-
{
|
|
971
|
-
"plugin": "logfire", "author": "Pydantic", "license": "MIT",
|
|
972
|
-
"repo": "pydantic/skills", "branch": "main",
|
|
973
|
-
"plugin_path": "plugins/logfire",
|
|
974
|
-
"category": "monitoring",
|
|
975
|
-
},
|
|
976
|
-
]
|
|
977
|
-
|
|
978
|
-
# 검증된 레포 라이선스 맵 (GitHub API 없이 빠르게 조회)
|
|
979
|
-
_KNOWN_REPO_LICENSES: Dict[str, str] = {
|
|
980
|
-
# Apache-2.0
|
|
981
|
-
"adobe/skills": "Apache-2.0", "awslabs/agent-plugins": "Apache-2.0",
|
|
982
|
-
"auth0/agent-skills": "Apache-2.0", "aws/agent-toolkit-for-aws": "Apache-2.0",
|
|
983
|
-
"carta/plugins": "Apache-2.0", "circlefin/skills": "Apache-2.0",
|
|
984
|
-
"clickhouse/clickhouse-docs": "Apache-2.0", "cloudflare/agents": "Apache-2.0",
|
|
985
|
-
"cockroachdb/claude-code": "Apache-2.0", "codspeed-hq/codspeed-claude": "Apache-2.0",
|
|
986
|
-
"DataDog/datadog-claude-code": "Apache-2.0", "datahub-project/datahub-skills": "Apache-2.0",
|
|
987
|
-
"neondatabase/agent-skills": "Apache-2.0", "PagerDuty/pd-ai-agents-plugins": "Apache-2.0",
|
|
988
|
-
"getpostman/postman-mcp-server": "Apache-2.0", "qdrant/qdrant-skills": "Apache-2.0",
|
|
989
|
-
"rootlyhq/rootly-plugins": "Apache-2.0", "snowflake-labs/snowflake-claude": "Apache-2.0",
|
|
990
|
-
"sumup/sumup-claude": "Apache-2.0", "zilliz-labs/zilliz-skills": "Apache-2.0",
|
|
991
|
-
"mercadopago/mercadopago-claude-marketplace": "Apache-2.0",
|
|
992
|
-
# MIT
|
|
993
|
-
"Airtable/skills": "MIT", "endorlabs/ai-plugins": "MIT",
|
|
994
|
-
"apollographql/apollo-claude-skills": "MIT", "appwrite/skills": "MIT",
|
|
995
|
-
"atlan-inc/claude-code-skills": "MIT", "boxer/boxerbox": "MIT",
|
|
996
|
-
"buildkite/claude-code": "MIT", "coderabbitai/coderabbit-skills": "MIT",
|
|
997
|
-
"CrowdStrike/crowdstrike-skills": "MIT", "microsoft/Dataverse-skills": "MIT",
|
|
998
|
-
"duckdb/duckdb-skills": "MIT", "expo/skills": "MIT",
|
|
999
|
-
"intercom/intercom-skills": "MIT", "pydantic/skills": "MIT",
|
|
1000
|
-
"mapbox/mapbox-skills": "MIT", "mintlify/mintlify-skills": "MIT",
|
|
1001
|
-
"miroapp/miro-ai": "MIT", "netlify/netlify-skills": "MIT",
|
|
1002
|
-
"pinecone-io/pinecone-skills": "MIT", "railwayapp/railway-skills": "MIT",
|
|
1003
|
-
"resend/resend-skills": "MIT", "sanity-io/sanity-skills": "MIT",
|
|
1004
|
-
"getsentry/sentry-ai-skills": "MIT", "Shopify/liquid-skills": "MIT",
|
|
1005
|
-
"slackapi/slack-skills": "MIT", "stripe/stripe-skills": "MIT",
|
|
1006
|
-
"twilio-labs/twilio-skills": "MIT", "workos/workos-skills": "MIT",
|
|
1007
|
-
"zoom/zoom-skills": "MIT", "aws-samples/sample-claude-code-plugins-for-startups": "MIT-0",
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
_SKILLS_MARKETPLACE_CACHE: List[Dict] = []
|
|
1011
|
-
_SKILLS_MARKETPLACE_FETCHED_AT: Optional[datetime] = None
|
|
1012
|
-
_SKILLS_MARKETPLACE_TTL = timedelta(hours=1)
|
|
1013
|
-
|
|
1014
|
-
def _extract_skill_desc(skill_md: str, fallback: str) -> str:
|
|
1015
|
-
for line in skill_md.splitlines():
|
|
1016
|
-
if line.startswith("description:"):
|
|
1017
|
-
return line.split(":", 1)[1].strip()
|
|
1018
|
-
return fallback
|
|
1019
|
-
|
|
1020
|
-
async def _fetch_plugin_skills(client: httpx.AsyncClient, source: Dict) -> List[Dict]:
|
|
1021
|
-
"""단일 소스에서 skill 목록을 fetch해 반환"""
|
|
1022
|
-
repo, branch, plugin_path = source["repo"], source["branch"], source["plugin_path"]
|
|
1023
|
-
raw_base = f"https://raw.githubusercontent.com/{repo}/{branch}"
|
|
1024
|
-
api_base = f"https://api.github.com/repos/{repo}/contents"
|
|
1025
|
-
homepage_base = f"https://github.com/{repo}/tree/{branch}"
|
|
1026
|
-
|
|
1027
|
-
dir_resp = await client.get(f"{api_base}/{plugin_path}/skills")
|
|
1028
|
-
if dir_resp.status_code != 200:
|
|
1029
|
-
return []
|
|
1030
|
-
skill_dirs = [f["name"] for f in dir_resp.json() if f["type"] == "dir"]
|
|
1031
|
-
|
|
1032
|
-
skills: List[Dict] = []
|
|
1033
|
-
for skill_name in skill_dirs:
|
|
1034
|
-
skill_md_url = f"{raw_base}/{plugin_path}/skills/{skill_name}/SKILL.md"
|
|
1035
|
-
sm_resp = await client.get(skill_md_url)
|
|
1036
|
-
if sm_resp.status_code != 200:
|
|
1037
|
-
continue
|
|
1038
|
-
skills.append({
|
|
1039
|
-
"plugin": source["plugin"],
|
|
1040
|
-
"skill": skill_name,
|
|
1041
|
-
"category": source.get("category", "development"),
|
|
1042
|
-
"description": _extract_skill_desc(sm_resp.text, source.get("description", "")),
|
|
1043
|
-
"skill_md_url": skill_md_url,
|
|
1044
|
-
"homepage": f"{homepage_base}/{plugin_path}/skills/{skill_name}",
|
|
1045
|
-
"license": source["license"],
|
|
1046
|
-
"author": source["author"],
|
|
1047
|
-
})
|
|
1048
|
-
return skills
|
|
1049
|
-
|
|
1050
|
-
async def _fetch_skills_marketplace() -> List[Dict]:
|
|
1051
|
-
global _SKILLS_MARKETPLACE_CACHE, _SKILLS_MARKETPLACE_FETCHED_AT
|
|
1052
|
-
now = datetime.now()
|
|
1053
|
-
if _SKILLS_MARKETPLACE_FETCHED_AT and (now - _SKILLS_MARKETPLACE_FETCHED_AT) < _SKILLS_MARKETPLACE_TTL:
|
|
1054
|
-
return _SKILLS_MARKETPLACE_CACHE
|
|
1055
|
-
try:
|
|
1056
|
-
result: List[Dict] = []
|
|
1057
|
-
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
1058
|
-
# ── Anthropic 공식 skills (Apache-2.0) ──────────────────────────
|
|
1059
|
-
mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
|
|
1060
|
-
mp_resp.raise_for_status()
|
|
1061
|
-
marketplace_json = mp_resp.json()
|
|
1062
|
-
anthropic_plugins = [
|
|
1063
|
-
p for p in marketplace_json.get("plugins", [])
|
|
1064
|
-
if (p.get("author") or {}).get("name") == "Anthropic"
|
|
1065
|
-
and isinstance(p.get("source"), str)
|
|
1066
|
-
and p["source"].startswith("./")
|
|
1067
|
-
]
|
|
1068
|
-
for plugin in anthropic_plugins:
|
|
1069
|
-
plugin_path = plugin["source"].lstrip("./")
|
|
1070
|
-
result.extend(await _fetch_plugin_skills(client, {
|
|
1071
|
-
"plugin": plugin["name"],
|
|
1072
|
-
"author": "Anthropic",
|
|
1073
|
-
"license": "Apache-2.0",
|
|
1074
|
-
"repo": "anthropics/claude-plugins-official",
|
|
1075
|
-
"branch": "main",
|
|
1076
|
-
"plugin_path": plugin_path,
|
|
1077
|
-
"category": plugin.get("category", "development"),
|
|
1078
|
-
"description": plugin.get("description", ""),
|
|
1079
|
-
}))
|
|
1080
|
-
# ── 검증된 서드파티 skills ────────────────────────────────────────
|
|
1081
|
-
for source in _THIRD_PARTY_SKILL_SOURCES:
|
|
1082
|
-
result.extend(await _fetch_plugin_skills(client, source))
|
|
1083
|
-
|
|
1084
|
-
_SKILLS_MARKETPLACE_CACHE = result
|
|
1085
|
-
_SKILLS_MARKETPLACE_FETCHED_AT = now
|
|
1086
|
-
logging.info("Fetched %d skills from marketplace (%d sources)",
|
|
1087
|
-
len(result), len(anthropic_plugins) + len(_THIRD_PARTY_SKILL_SOURCES))
|
|
1088
|
-
except Exception as e:
|
|
1089
|
-
logging.warning("Failed to fetch skills marketplace: %s", e)
|
|
1090
|
-
return _SKILLS_MARKETPLACE_CACHE
|
|
1091
|
-
|
|
1092
|
-
# ── Plugin Directory ──────────────────────────────────────────────────────────
|
|
1093
|
-
_PLUGIN_DIRECTORY_CACHE: List[Dict] = []
|
|
1094
|
-
_PLUGIN_DIRECTORY_FETCHED_AT: Optional[datetime] = None
|
|
1095
|
-
_PLUGIN_DIRECTORY_TTL = timedelta(hours=1)
|
|
1096
|
-
_OPEN_LICENSES = {"Apache-2.0", "MIT", "MIT-0", "CC-BY-4.0"}
|
|
1097
|
-
_REPO_LICENSE_CACHE: Dict[str, str] = {}
|
|
1098
|
-
|
|
1099
|
-
async def _get_repo_license(client: httpx.AsyncClient, repo: str) -> str:
|
|
1100
|
-
if repo in _REPO_LICENSE_CACHE:
|
|
1101
|
-
return _REPO_LICENSE_CACHE[repo]
|
|
1102
|
-
if repo in _KNOWN_REPO_LICENSES:
|
|
1103
|
-
_REPO_LICENSE_CACHE[repo] = _KNOWN_REPO_LICENSES[repo]
|
|
1104
|
-
return _KNOWN_REPO_LICENSES[repo]
|
|
1105
|
-
try:
|
|
1106
|
-
r = await client.get(f"https://api.github.com/repos/{repo}", timeout=5.0)
|
|
1107
|
-
lic = (r.json().get("license") or {}).get("spdx_id", "") if r.status_code == 200 else ""
|
|
1108
|
-
except Exception:
|
|
1109
|
-
lic = ""
|
|
1110
|
-
_REPO_LICENSE_CACHE[repo] = lic
|
|
1111
|
-
return lic
|
|
1112
|
-
|
|
1113
|
-
async def _fetch_plugin_directory() -> List[Dict]:
|
|
1114
|
-
global _PLUGIN_DIRECTORY_CACHE, _PLUGIN_DIRECTORY_FETCHED_AT
|
|
1115
|
-
now = datetime.now()
|
|
1116
|
-
if _PLUGIN_DIRECTORY_FETCHED_AT and (now - _PLUGIN_DIRECTORY_FETCHED_AT) < _PLUGIN_DIRECTORY_TTL:
|
|
1117
|
-
return _PLUGIN_DIRECTORY_CACHE
|
|
1118
|
-
try:
|
|
1119
|
-
result: List[Dict] = []
|
|
1120
|
-
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
1121
|
-
mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
|
|
1122
|
-
mp_resp.raise_for_status()
|
|
1123
|
-
plugins = mp_resp.json().get("plugins", [])
|
|
1124
|
-
|
|
1125
|
-
for p in plugins:
|
|
1126
|
-
author = (p.get("author") or {}).get("name", "")
|
|
1127
|
-
src = p.get("source", {})
|
|
1128
|
-
|
|
1129
|
-
# Anthropic 같은 레포 플러그인 → Apache-2.0 확인됨
|
|
1130
|
-
if isinstance(src, str) and src.startswith("./") and author == "Anthropic":
|
|
1131
|
-
plugin_path = src.lstrip("./")
|
|
1132
|
-
result.append({
|
|
1133
|
-
"name": p["name"],
|
|
1134
|
-
"description": p.get("description", ""),
|
|
1135
|
-
"category": p.get("category", ""),
|
|
1136
|
-
"author": author,
|
|
1137
|
-
"license": "Apache-2.0",
|
|
1138
|
-
"homepage": p.get("homepage") or f"https://github.com/anthropics/claude-plugins-official/tree/main/{plugin_path}",
|
|
1139
|
-
"source_type": "anthropic",
|
|
1140
|
-
})
|
|
1141
|
-
continue
|
|
1142
|
-
|
|
1143
|
-
# 외부 레포 플러그인 → 라이선스 확인
|
|
1144
|
-
if not isinstance(src, dict):
|
|
1145
|
-
continue
|
|
1146
|
-
repo_url = src.get("url", "").replace("https://github.com/", "").replace(".git", "").split("/tree/")[0]
|
|
1147
|
-
if not repo_url:
|
|
1148
|
-
continue
|
|
1149
|
-
license_id = await _get_repo_license(client, repo_url)
|
|
1150
|
-
if license_id not in _OPEN_LICENSES:
|
|
1151
|
-
continue
|
|
1152
|
-
result.append({
|
|
1153
|
-
"name": p["name"],
|
|
1154
|
-
"description": p.get("description", ""),
|
|
1155
|
-
"category": p.get("category", ""),
|
|
1156
|
-
"author": author or repo_url.split("/")[0],
|
|
1157
|
-
"license": license_id,
|
|
1158
|
-
"homepage": p.get("homepage") or f"https://github.com/{repo_url}",
|
|
1159
|
-
"source_type": "third-party",
|
|
1160
|
-
})
|
|
1161
|
-
|
|
1162
|
-
_PLUGIN_DIRECTORY_CACHE = result
|
|
1163
|
-
_PLUGIN_DIRECTORY_FETCHED_AT = now
|
|
1164
|
-
logging.info("Fetched plugin directory: %d open-source plugins", len(result))
|
|
1165
|
-
except Exception as e:
|
|
1166
|
-
logging.warning("Failed to fetch plugin directory: %s", e)
|
|
1167
|
-
return _PLUGIN_DIRECTORY_CACHE
|
|
1168
|
-
|
|
1169
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
1170
|
-
|
|
1171
|
-
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
|
|
1172
|
-
|
|
1173
|
-
async def install_skill(plugin: str, skill: str) -> Dict:
|
|
1174
|
-
marketplace = await _fetch_skills_marketplace()
|
|
1175
|
-
entry = next((s for s in marketplace if s["plugin"] == plugin and s["skill"] == skill), None)
|
|
1176
|
-
if not entry:
|
|
1177
|
-
raise HTTPException(status_code=404, detail=f"Skill '{plugin}/{skill}' not found in marketplace")
|
|
1178
|
-
skill_dir = SKILLS_DIR / skill
|
|
1179
|
-
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
1180
|
-
skill_md_path = skill_dir / "SKILL.md"
|
|
1181
|
-
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
1182
|
-
resp = await client.get(entry["skill_md_url"])
|
|
1183
|
-
resp.raise_for_status()
|
|
1184
|
-
content = resp.text
|
|
1185
|
-
# 출처 표기 (Apache-2.0 / MIT 공통)
|
|
1186
|
-
repo_hint = entry.get("homepage", "")
|
|
1187
|
-
attribution = f"<!-- Source: {repo_hint}, {entry['license']} -->\n"
|
|
1188
|
-
if not content.startswith("<!--"):
|
|
1189
|
-
content = attribution + content
|
|
1190
|
-
skill_md_path.write_text(content, encoding="utf-8")
|
|
1191
|
-
risk_path = skill_dir / "risk.json"
|
|
1192
|
-
if not risk_path.exists():
|
|
1193
|
-
risk_path.write_text(json.dumps({
|
|
1194
|
-
"risk": "read", "destructive": False,
|
|
1195
|
-
"shell": False, "network": False,
|
|
1196
|
-
"auto_approve": True, "sandbox": "workspace", "rollback": "none"
|
|
1197
|
-
}, indent=2), encoding="utf-8")
|
|
1198
|
-
return {
|
|
1199
|
-
"status": "installed",
|
|
1200
|
-
"plugin": plugin,
|
|
1201
|
-
"skill": skill,
|
|
1202
|
-
"path": str(skill_dir),
|
|
1203
|
-
"license": entry["license"],
|
|
1204
|
-
"author": entry["author"],
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
1208
517
|
|
|
1209
518
|
def load_users():
|
|
1210
519
|
if not os.path.exists(USERS_FILE):
|
|
@@ -2216,23 +1525,23 @@ async def login(req: UserLogin, request: Request):
|
|
|
2216
1525
|
|
|
2217
1526
|
@app.get("/auth/sso/config")
|
|
2218
1527
|
async def sso_config():
|
|
2219
|
-
|
|
2220
|
-
return {"enabled": enabled, "provider_name": SSO_PROVIDER_NAME if enabled else ""}
|
|
1528
|
+
return public_sso_config()
|
|
2221
1529
|
|
|
2222
1530
|
@app.get("/auth/sso/login")
|
|
2223
1531
|
async def sso_login():
|
|
2224
1532
|
from urllib.parse import urlencode
|
|
2225
1533
|
from fastapi.responses import RedirectResponse as _Redirect
|
|
1534
|
+
settings = get_sso_settings()
|
|
2226
1535
|
discovery = await _get_sso_discovery()
|
|
2227
|
-
if not discovery:
|
|
1536
|
+
if not settings.get("enabled") or not discovery:
|
|
2228
1537
|
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
2229
1538
|
state = secrets.token_urlsafe(16)
|
|
2230
1539
|
_sso_states[state] = time.time()
|
|
2231
1540
|
params = urlencode({
|
|
2232
|
-
"client_id":
|
|
1541
|
+
"client_id": settings["client_id"],
|
|
2233
1542
|
"response_type": "code",
|
|
2234
|
-
"redirect_uri":
|
|
2235
|
-
"scope": "openid email profile",
|
|
1543
|
+
"redirect_uri": settings["redirect_uri"],
|
|
1544
|
+
"scope": settings.get("scopes") or "openid email profile",
|
|
2236
1545
|
"state": state,
|
|
2237
1546
|
})
|
|
2238
1547
|
return _Redirect(f"{discovery['authorization_endpoint']}?{params}")
|
|
@@ -2246,17 +1555,18 @@ async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
|
2246
1555
|
ts = _sso_states.pop(state, None)
|
|
2247
1556
|
if ts is None or time.time() - ts > 300:
|
|
2248
1557
|
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
1558
|
+
settings = get_sso_settings()
|
|
2249
1559
|
discovery = await _get_sso_discovery()
|
|
2250
|
-
if not discovery:
|
|
1560
|
+
if not settings.get("enabled") or not discovery:
|
|
2251
1561
|
raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
|
|
2252
1562
|
import httpx as _httpx
|
|
2253
1563
|
async with _httpx.AsyncClient() as c:
|
|
2254
1564
|
r = await c.post(discovery["token_endpoint"], data={
|
|
2255
1565
|
"grant_type": "authorization_code",
|
|
2256
1566
|
"code": code,
|
|
2257
|
-
"redirect_uri":
|
|
2258
|
-
"client_id":
|
|
2259
|
-
"client_secret":
|
|
1567
|
+
"redirect_uri": settings["redirect_uri"],
|
|
1568
|
+
"client_id": settings["client_id"],
|
|
1569
|
+
"client_secret": settings["client_secret"],
|
|
2260
1570
|
}, headers={"Accept": "application/json"}, timeout=15)
|
|
2261
1571
|
tokens = r.json()
|
|
2262
1572
|
id_token = tokens.get("id_token")
|
|
@@ -2468,6 +1778,25 @@ async def admin_invite_link(request: Request):
|
|
|
2468
1778
|
url = f"{scheme}://{host}/"
|
|
2469
1779
|
return {"invite_url": url, "invite_code": INVITE_CODE, "gate_enabled": INVITE_GATE_ENABLED}
|
|
2470
1780
|
|
|
1781
|
+
@app.get("/admin/sso")
|
|
1782
|
+
async def admin_sso(request: Request):
|
|
1783
|
+
require_admin(request)
|
|
1784
|
+
return public_sso_config()
|
|
1785
|
+
|
|
1786
|
+
@app.patch("/admin/sso")
|
|
1787
|
+
async def admin_update_sso(req: SsoConfigUpdate, request: Request):
|
|
1788
|
+
admin_email, _ = require_admin(request)
|
|
1789
|
+
update = req.dict(exclude_unset=True)
|
|
1790
|
+
saved = save_sso_config(update)
|
|
1791
|
+
append_audit_event(
|
|
1792
|
+
"sso_config_update",
|
|
1793
|
+
user_email=admin_email,
|
|
1794
|
+
provider_name=saved.get("provider_name"),
|
|
1795
|
+
discovery_url=saved.get("discovery_url"),
|
|
1796
|
+
enabled=bool(saved.get("enabled")),
|
|
1797
|
+
)
|
|
1798
|
+
return public_sso_config(saved)
|
|
1799
|
+
|
|
2471
1800
|
# ── Invitation Logic ────────────────────────────────────────────────────────
|
|
2472
1801
|
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
|
|
2473
1802
|
INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
|
|
@@ -2495,7 +1824,7 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
|
|
|
2495
1824
|
<div style="font-size:48px; margin-bottom:20px;">🔒</div>
|
|
2496
1825
|
<h1 style="color:#378ADD; margin:0; font-size:24px;">Invitation Required</h1>
|
|
2497
1826
|
<p style="color:#94a3b8; margin:20px 0; line-height:1.6;">이 서비스는 비공개로 운영되고 있습니다.<br>선생님께 받은 <b>초대용 전용 링크</b>를 통해 접속해 주세요.</p>
|
|
2498
|
-
<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
|
|
1827
|
+
<div style="margin-top:30px; padding-top:20px; border-top:1px solid rgba(255,255,255,0.05); font-size:11px; color:rgba(255,255,255,0.2); letter-spacing:1px;">LATTICE AI</div>
|
|
2499
1828
|
</div>
|
|
2500
1829
|
</body>
|
|
2501
1830
|
""", status_code=403)
|
|
@@ -2550,6 +1879,48 @@ async def status():
|
|
|
2550
1879
|
}
|
|
2551
1880
|
|
|
2552
1881
|
|
|
1882
|
+
@app.get("/local/sysinfo")
|
|
1883
|
+
async def local_sysinfo(request: Request):
|
|
1884
|
+
"""CPU / RAM / GPU(MLX) 사용량을 반환합니다."""
|
|
1885
|
+
require_user(request)
|
|
1886
|
+
import subprocess, re as _re
|
|
1887
|
+
result = {"cpu_pct": 0.0, "ram_pct": 0.0, "gpu_mem_pct": 0.0, "gpu_mem_gb": 0.0}
|
|
1888
|
+
try:
|
|
1889
|
+
# CPU
|
|
1890
|
+
top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
|
|
1891
|
+
for line in top_out.splitlines():
|
|
1892
|
+
if "CPU usage" in line:
|
|
1893
|
+
m = _re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
|
|
1894
|
+
if m:
|
|
1895
|
+
result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
|
|
1896
|
+
# RAM
|
|
1897
|
+
vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
|
|
1898
|
+
page_size = 16384
|
|
1899
|
+
pages: dict = {}
|
|
1900
|
+
for line in vm_out.splitlines():
|
|
1901
|
+
for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
|
|
1902
|
+
if line.startswith(key):
|
|
1903
|
+
m = _re.search(r"(\d+)", line)
|
|
1904
|
+
if m:
|
|
1905
|
+
pages[key] = int(m.group(1))
|
|
1906
|
+
total = sum(pages.values())
|
|
1907
|
+
used = total - pages.get("Pages free", 0)
|
|
1908
|
+
result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
|
|
1909
|
+
# GPU (MLX / Apple Silicon unified memory)
|
|
1910
|
+
try:
|
|
1911
|
+
import mlx.core as _mx
|
|
1912
|
+
hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
|
|
1913
|
+
total_bytes = int(hw_out.strip())
|
|
1914
|
+
gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
|
|
1915
|
+
result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
|
|
1916
|
+
result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
|
|
1917
|
+
except Exception:
|
|
1918
|
+
pass
|
|
1919
|
+
except Exception as e:
|
|
1920
|
+
result["error"] = str(e)
|
|
1921
|
+
return result
|
|
1922
|
+
|
|
1923
|
+
|
|
2553
1924
|
|
|
2554
1925
|
|
|
2555
1926
|
# ── Request / Response Models ──────────────────────────────────────────────────
|
|
@@ -3208,31 +2579,224 @@ def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
|
|
|
3208
2579
|
)
|
|
3209
2580
|
return has_config and has_weights and has_tokenizer
|
|
3210
2581
|
|
|
3211
|
-
|
|
2582
|
+
|
|
2583
|
+
def model_download_progress_payload(
|
|
2584
|
+
stage: str,
|
|
2585
|
+
message: str,
|
|
2586
|
+
*,
|
|
2587
|
+
percent: Optional[float] = None,
|
|
2588
|
+
detail: Optional[str] = None,
|
|
2589
|
+
downloaded_bytes: Optional[int] = None,
|
|
2590
|
+
total_bytes: Optional[int] = None,
|
|
2591
|
+
eta_seconds: Optional[float] = None,
|
|
2592
|
+
file: Optional[str] = None,
|
|
2593
|
+
indeterminate: bool = False,
|
|
2594
|
+
) -> Dict[str, object]:
|
|
2595
|
+
payload: Dict[str, object] = {
|
|
2596
|
+
"stage": stage,
|
|
2597
|
+
"message": message,
|
|
2598
|
+
"indeterminate": indeterminate,
|
|
2599
|
+
"ts": time.time(),
|
|
2600
|
+
}
|
|
2601
|
+
if percent is not None:
|
|
2602
|
+
payload["percent"] = max(0, min(100, round(float(percent), 1)))
|
|
2603
|
+
if detail:
|
|
2604
|
+
payload["detail"] = detail
|
|
2605
|
+
if downloaded_bytes is not None:
|
|
2606
|
+
payload["downloaded_bytes"] = max(0, int(downloaded_bytes))
|
|
2607
|
+
if total_bytes is not None:
|
|
2608
|
+
payload["total_bytes"] = max(0, int(total_bytes))
|
|
2609
|
+
if eta_seconds is not None:
|
|
2610
|
+
payload["eta_seconds"] = max(0, round(float(eta_seconds)))
|
|
2611
|
+
if file:
|
|
2612
|
+
payload["file"] = file
|
|
2613
|
+
return payload
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
def estimate_eta_seconds(started_at: float, percent: Optional[float]) -> Optional[float]:
|
|
2617
|
+
if percent is None or percent <= 0 or percent >= 100:
|
|
2618
|
+
return None
|
|
2619
|
+
elapsed = max(0.0, time.time() - started_at)
|
|
2620
|
+
return elapsed * (100.0 - percent) / percent
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def hf_repo_files_with_sizes(repo_id: str) -> List[Dict[str, object]]:
|
|
2624
|
+
from huggingface_hub import HfApi
|
|
2625
|
+
|
|
2626
|
+
api = HfApi()
|
|
2627
|
+
try:
|
|
2628
|
+
info = api.model_info(repo_id, files_metadata=True)
|
|
2629
|
+
files = []
|
|
2630
|
+
for sibling in getattr(info, "siblings", []) or []:
|
|
2631
|
+
name = str(getattr(sibling, "rfilename", "") or "").strip()
|
|
2632
|
+
if not name or name.endswith("/"):
|
|
2633
|
+
continue
|
|
2634
|
+
files.append({"name": name, "size": int(getattr(sibling, "size", 0) or 0)})
|
|
2635
|
+
if files:
|
|
2636
|
+
return files
|
|
2637
|
+
except TypeError:
|
|
2638
|
+
pass
|
|
2639
|
+
except Exception as e:
|
|
2640
|
+
logging.warning("huggingface model_info failed for %s: %s", repo_id, e)
|
|
2641
|
+
|
|
2642
|
+
return [{"name": str(name), "size": 0} for name in api.list_repo_files(repo_id) if str(name).strip()]
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
def download_hf_model(
|
|
2646
|
+
repo_id: str,
|
|
2647
|
+
provider: str = "local_mlx",
|
|
2648
|
+
progress_emit=None,
|
|
2649
|
+
) -> Dict[str, object]:
|
|
3212
2650
|
if importlib.util.find_spec("huggingface_hub") is None:
|
|
3213
2651
|
raise HTTPException(status_code=400, detail="huggingface_hub가 없습니다. 먼저 MLX runtime 설치를 진행해 주세요.")
|
|
3214
2652
|
|
|
3215
2653
|
target_dir = hf_model_dir(repo_id)
|
|
3216
2654
|
if hf_model_ready(repo_id, provider):
|
|
2655
|
+
if progress_emit:
|
|
2656
|
+
progress_emit(model_download_progress_payload(
|
|
2657
|
+
"download",
|
|
2658
|
+
"이미 다운로드된 모델을 확인했습니다.",
|
|
2659
|
+
percent=100,
|
|
2660
|
+
downloaded_bytes=0,
|
|
2661
|
+
total_bytes=0,
|
|
2662
|
+
eta_seconds=0,
|
|
2663
|
+
))
|
|
3217
2664
|
return {"model": repo_id, "path": str(target_dir), "cached": True}
|
|
3218
2665
|
|
|
3219
2666
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
3220
2667
|
try:
|
|
3221
|
-
from huggingface_hub import
|
|
2668
|
+
from huggingface_hub import hf_hub_download
|
|
3222
2669
|
|
|
2670
|
+
started_at = time.time()
|
|
2671
|
+
all_files = hf_repo_files_with_sizes(repo_id)
|
|
3223
2672
|
if provider == "llamacpp":
|
|
3224
|
-
|
|
3225
|
-
|
|
2673
|
+
ggufs = sorted(
|
|
2674
|
+
[item for item in all_files if str(item["name"]).lower().endswith(".gguf")],
|
|
2675
|
+
key=lambda item: str(item["name"]),
|
|
2676
|
+
)
|
|
3226
2677
|
if not ggufs:
|
|
3227
2678
|
raise RuntimeError("GGUF 파일을 찾지 못했습니다.")
|
|
3228
2679
|
preference = ("q4_k_m", "q4_0", "q4_k_s", "q3_k_m", "q2_k")
|
|
3229
|
-
|
|
3230
|
-
(
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
2680
|
+
selected_files = [
|
|
2681
|
+
next(
|
|
2682
|
+
(item for pref in preference for item in ggufs if pref in str(item["name"]).lower()),
|
|
2683
|
+
ggufs[0],
|
|
2684
|
+
)
|
|
2685
|
+
]
|
|
3234
2686
|
else:
|
|
3235
|
-
|
|
2687
|
+
selected_files = all_files
|
|
2688
|
+
|
|
2689
|
+
total_bytes = sum(int(item.get("size") or 0) for item in selected_files) or None
|
|
2690
|
+
downloaded_bytes = 0
|
|
2691
|
+
total_files = max(1, len(selected_files))
|
|
2692
|
+
if progress_emit:
|
|
2693
|
+
progress_emit(model_download_progress_payload(
|
|
2694
|
+
"download",
|
|
2695
|
+
"모델 파일 정보를 확인했습니다.",
|
|
2696
|
+
percent=0,
|
|
2697
|
+
downloaded_bytes=0,
|
|
2698
|
+
total_bytes=total_bytes,
|
|
2699
|
+
indeterminate=total_bytes is None,
|
|
2700
|
+
))
|
|
2701
|
+
|
|
2702
|
+
for index, item in enumerate(selected_files, start=1):
|
|
2703
|
+
filename = str(item["name"])
|
|
2704
|
+
size = int(item.get("size") or 0)
|
|
2705
|
+
tqdm_class = None
|
|
2706
|
+
if progress_emit:
|
|
2707
|
+
current_percent = (
|
|
2708
|
+
(downloaded_bytes / total_bytes) * 100 if total_bytes else ((index - 1) / total_files) * 100
|
|
2709
|
+
)
|
|
2710
|
+
progress_emit(model_download_progress_payload(
|
|
2711
|
+
"download",
|
|
2712
|
+
"모델 다운로드 중입니다.",
|
|
2713
|
+
percent=current_percent,
|
|
2714
|
+
detail=filename,
|
|
2715
|
+
downloaded_bytes=downloaded_bytes,
|
|
2716
|
+
total_bytes=total_bytes,
|
|
2717
|
+
eta_seconds=estimate_eta_seconds(started_at, current_percent),
|
|
2718
|
+
file=filename,
|
|
2719
|
+
indeterminate=total_bytes is None and total_files <= 1,
|
|
2720
|
+
))
|
|
2721
|
+
try:
|
|
2722
|
+
from tqdm.auto import tqdm as base_tqdm
|
|
2723
|
+
|
|
2724
|
+
downloaded_before = downloaded_bytes
|
|
2725
|
+
last_emit = {"at": 0.0, "percent": -1.0}
|
|
2726
|
+
|
|
2727
|
+
def emit_byte_progress(done_bytes: float) -> None:
|
|
2728
|
+
done = max(0, int(done_bytes or 0))
|
|
2729
|
+
if total_bytes:
|
|
2730
|
+
aggregate = min(total_bytes, downloaded_before + done)
|
|
2731
|
+
percent = (aggregate / total_bytes) * 100
|
|
2732
|
+
else:
|
|
2733
|
+
file_total = size or done
|
|
2734
|
+
file_ratio = min(1.0, done / file_total) if file_total else 0.0
|
|
2735
|
+
aggregate = downloaded_before + done
|
|
2736
|
+
percent = ((index - 1) + file_ratio) / total_files * 100
|
|
2737
|
+
now = time.time()
|
|
2738
|
+
if percent < 100 and now - last_emit["at"] < 0.5 and percent - last_emit["percent"] < 0.3:
|
|
2739
|
+
return
|
|
2740
|
+
last_emit["at"] = now
|
|
2741
|
+
last_emit["percent"] = percent
|
|
2742
|
+
progress_emit(model_download_progress_payload(
|
|
2743
|
+
"download",
|
|
2744
|
+
"모델 다운로드 중입니다.",
|
|
2745
|
+
percent=percent,
|
|
2746
|
+
detail=filename,
|
|
2747
|
+
downloaded_bytes=aggregate,
|
|
2748
|
+
total_bytes=total_bytes,
|
|
2749
|
+
eta_seconds=estimate_eta_seconds(started_at, percent),
|
|
2750
|
+
file=filename,
|
|
2751
|
+
indeterminate=total_bytes is None and total_files <= 1,
|
|
2752
|
+
))
|
|
2753
|
+
|
|
2754
|
+
class ProgressTqdm(base_tqdm):
|
|
2755
|
+
def update(self, n=1):
|
|
2756
|
+
result = super().update(n)
|
|
2757
|
+
emit_byte_progress(float(getattr(self, "n", 0) or 0))
|
|
2758
|
+
return result
|
|
2759
|
+
|
|
2760
|
+
tqdm_class = ProgressTqdm
|
|
2761
|
+
except Exception:
|
|
2762
|
+
tqdm_class = None
|
|
2763
|
+
local_path = hf_hub_download(
|
|
2764
|
+
repo_id=repo_id,
|
|
2765
|
+
filename=filename,
|
|
2766
|
+
local_dir=str(target_dir),
|
|
2767
|
+
tqdm_class=tqdm_class,
|
|
2768
|
+
)
|
|
2769
|
+
if size <= 0:
|
|
2770
|
+
try:
|
|
2771
|
+
size = Path(local_path).stat().st_size
|
|
2772
|
+
except OSError:
|
|
2773
|
+
size = 0
|
|
2774
|
+
downloaded_bytes += size
|
|
2775
|
+
if progress_emit:
|
|
2776
|
+
current_percent = (
|
|
2777
|
+
(downloaded_bytes / total_bytes) * 100 if total_bytes else (index / total_files) * 100
|
|
2778
|
+
)
|
|
2779
|
+
progress_emit(model_download_progress_payload(
|
|
2780
|
+
"download",
|
|
2781
|
+
"모델 다운로드 중입니다.",
|
|
2782
|
+
percent=current_percent,
|
|
2783
|
+
detail=filename,
|
|
2784
|
+
downloaded_bytes=downloaded_bytes,
|
|
2785
|
+
total_bytes=total_bytes,
|
|
2786
|
+
eta_seconds=estimate_eta_seconds(started_at, current_percent),
|
|
2787
|
+
file=filename,
|
|
2788
|
+
indeterminate=False,
|
|
2789
|
+
))
|
|
2790
|
+
|
|
2791
|
+
if progress_emit:
|
|
2792
|
+
progress_emit(model_download_progress_payload(
|
|
2793
|
+
"download",
|
|
2794
|
+
"모델 다운로드가 완료되었습니다.",
|
|
2795
|
+
percent=100,
|
|
2796
|
+
downloaded_bytes=downloaded_bytes,
|
|
2797
|
+
total_bytes=total_bytes or downloaded_bytes,
|
|
2798
|
+
eta_seconds=0,
|
|
2799
|
+
))
|
|
3236
2800
|
except Exception as e:
|
|
3237
2801
|
raise HTTPException(status_code=500, detail=f"{repo_id} 다운로드 실패: {str(e)[-2000:]}")
|
|
3238
2802
|
|
|
@@ -3242,6 +2806,75 @@ def download_hf_model(repo_id: str, provider: str = "local_mlx") -> Dict[str, ob
|
|
|
3242
2806
|
return {"model": repo_id, "path": str(target_dir), "cached": False}
|
|
3243
2807
|
|
|
3244
2808
|
|
|
2809
|
+
def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
|
|
2810
|
+
started_at = time.time()
|
|
2811
|
+
if progress_emit:
|
|
2812
|
+
progress_emit(model_download_progress_payload(
|
|
2813
|
+
"download",
|
|
2814
|
+
"Ollama 모델 다운로드를 시작합니다.",
|
|
2815
|
+
percent=0,
|
|
2816
|
+
detail=model_name,
|
|
2817
|
+
indeterminate=True,
|
|
2818
|
+
))
|
|
2819
|
+
process = subprocess.Popen(
|
|
2820
|
+
["ollama", "pull", model_name],
|
|
2821
|
+
stdout=subprocess.PIPE,
|
|
2822
|
+
stderr=subprocess.STDOUT,
|
|
2823
|
+
text=True,
|
|
2824
|
+
bufsize=1,
|
|
2825
|
+
)
|
|
2826
|
+
last_percent: Optional[float] = None
|
|
2827
|
+
lines: List[str] = []
|
|
2828
|
+
try:
|
|
2829
|
+
assert process.stdout is not None
|
|
2830
|
+
for raw_line in process.stdout:
|
|
2831
|
+
for part in re.split(r"[\r\n]+", raw_line):
|
|
2832
|
+
line = part.strip()
|
|
2833
|
+
if not line:
|
|
2834
|
+
continue
|
|
2835
|
+
lines.append(line)
|
|
2836
|
+
match = re.search(r"(\d{1,3}(?:\.\d+)?)\s*%", line)
|
|
2837
|
+
if match:
|
|
2838
|
+
last_percent = min(100.0, float(match.group(1)))
|
|
2839
|
+
if progress_emit:
|
|
2840
|
+
progress_emit(model_download_progress_payload(
|
|
2841
|
+
"download",
|
|
2842
|
+
"Ollama 모델 다운로드 중입니다.",
|
|
2843
|
+
percent=last_percent,
|
|
2844
|
+
detail=line[-180:],
|
|
2845
|
+
eta_seconds=estimate_eta_seconds(started_at, last_percent),
|
|
2846
|
+
indeterminate=False,
|
|
2847
|
+
))
|
|
2848
|
+
elif progress_emit:
|
|
2849
|
+
progress_emit(model_download_progress_payload(
|
|
2850
|
+
"download",
|
|
2851
|
+
"Ollama 모델 다운로드 중입니다.",
|
|
2852
|
+
percent=last_percent,
|
|
2853
|
+
detail=line[-180:],
|
|
2854
|
+
eta_seconds=estimate_eta_seconds(started_at, last_percent),
|
|
2855
|
+
indeterminate=last_percent is None,
|
|
2856
|
+
))
|
|
2857
|
+
returncode = process.wait()
|
|
2858
|
+
except Exception:
|
|
2859
|
+
process.kill()
|
|
2860
|
+
raise
|
|
2861
|
+
|
|
2862
|
+
if returncode != 0:
|
|
2863
|
+
tail = "\n".join(lines[-12:])
|
|
2864
|
+
raise HTTPException(status_code=500, detail=tail[-2000:] or "Ollama 모델 다운로드 실패")
|
|
2865
|
+
|
|
2866
|
+
if progress_emit:
|
|
2867
|
+
progress_emit(model_download_progress_payload(
|
|
2868
|
+
"download",
|
|
2869
|
+
"Ollama 모델 다운로드가 완료되었습니다.",
|
|
2870
|
+
percent=100,
|
|
2871
|
+
detail=model_name,
|
|
2872
|
+
eta_seconds=0,
|
|
2873
|
+
indeterminate=False,
|
|
2874
|
+
))
|
|
2875
|
+
return {"provider": "ollama", "model": model_name, "returncode": returncode}
|
|
2876
|
+
|
|
2877
|
+
|
|
3245
2878
|
def get_ollama_pulled_models() -> set:
|
|
3246
2879
|
if not shutil.which("ollama"):
|
|
3247
2880
|
return set()
|
|
@@ -3806,6 +3439,227 @@ async def prepare_and_load_model(
|
|
|
3806
3439
|
"download": download_result,
|
|
3807
3440
|
}
|
|
3808
3441
|
|
|
3442
|
+
|
|
3443
|
+
def sse_event(event: str, data: Dict[str, object]) -> str:
|
|
3444
|
+
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
3445
|
+
|
|
3446
|
+
|
|
3447
|
+
async def prepare_and_load_model_stream(
|
|
3448
|
+
model_id: str,
|
|
3449
|
+
request: Request,
|
|
3450
|
+
engine: Optional[str] = None,
|
|
3451
|
+
user_email: Optional[str] = None,
|
|
3452
|
+
) -> AsyncIterator[str]:
|
|
3453
|
+
model_id = normalize_local_model_request(model_id, engine)
|
|
3454
|
+
if not model_id:
|
|
3455
|
+
raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
|
|
3456
|
+
|
|
3457
|
+
parsed_provider, parsed_model = parse_model_ref(model_id)
|
|
3458
|
+
if parsed_provider == "mlx":
|
|
3459
|
+
parsed_provider = "local_mlx"
|
|
3460
|
+
|
|
3461
|
+
work_queue: "queue.Queue[Dict[str, object]]" = queue.Queue()
|
|
3462
|
+
work_result: Dict[str, object] = {}
|
|
3463
|
+
|
|
3464
|
+
def emit_progress(payload: Dict[str, object]) -> None:
|
|
3465
|
+
work_queue.put({"kind": "progress", "data": payload})
|
|
3466
|
+
|
|
3467
|
+
def blocking_prepare() -> None:
|
|
3468
|
+
try:
|
|
3469
|
+
local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
|
|
3470
|
+
install_result: Dict[str, object] = {}
|
|
3471
|
+
download_result: Optional[Dict[str, object]] = None
|
|
3472
|
+
prepared_model_id = model_id
|
|
3473
|
+
prepared_model_name = parsed_model
|
|
3474
|
+
|
|
3475
|
+
if parsed_provider in local_engines:
|
|
3476
|
+
emit_progress(model_download_progress_payload(
|
|
3477
|
+
"engine",
|
|
3478
|
+
"실행 엔진을 확인하는 중입니다.",
|
|
3479
|
+
percent=2,
|
|
3480
|
+
indeterminate=True,
|
|
3481
|
+
))
|
|
3482
|
+
install_result = ensure_engine_ready(parsed_provider)
|
|
3483
|
+
emit_progress(model_download_progress_payload(
|
|
3484
|
+
"engine",
|
|
3485
|
+
"실행 엔진 준비가 완료되었습니다.",
|
|
3486
|
+
percent=10,
|
|
3487
|
+
indeterminate=False,
|
|
3488
|
+
))
|
|
3489
|
+
|
|
3490
|
+
if parsed_provider == "local_mlx":
|
|
3491
|
+
explicit_path = Path(parsed_model).expanduser()
|
|
3492
|
+
if explicit_path.exists():
|
|
3493
|
+
download_result = {"model": parsed_model, "path": str(explicit_path), "cached": True}
|
|
3494
|
+
emit_progress(model_download_progress_payload(
|
|
3495
|
+
"download",
|
|
3496
|
+
"로컬 모델 경로를 확인했습니다.",
|
|
3497
|
+
percent=100,
|
|
3498
|
+
detail=str(explicit_path),
|
|
3499
|
+
eta_seconds=0,
|
|
3500
|
+
))
|
|
3501
|
+
elif not hf_model_ready(parsed_model, "local_mlx"):
|
|
3502
|
+
download_result = download_hf_model(parsed_model, "local_mlx", progress_emit=emit_progress)
|
|
3503
|
+
else:
|
|
3504
|
+
download_result = {"model": parsed_model, "path": str(hf_model_dir(parsed_model)), "cached": True}
|
|
3505
|
+
emit_progress(model_download_progress_payload(
|
|
3506
|
+
"download",
|
|
3507
|
+
"이미 다운로드된 모델을 확인했습니다.",
|
|
3508
|
+
percent=100,
|
|
3509
|
+
eta_seconds=0,
|
|
3510
|
+
))
|
|
3511
|
+
elif parsed_provider == "ollama":
|
|
3512
|
+
emit_progress(model_download_progress_payload(
|
|
3513
|
+
"engine",
|
|
3514
|
+
"Ollama 서버를 확인하는 중입니다.",
|
|
3515
|
+
percent=12,
|
|
3516
|
+
indeterminate=True,
|
|
3517
|
+
))
|
|
3518
|
+
ensure_ollama_server()
|
|
3519
|
+
if parsed_model not in get_ollama_pulled_models():
|
|
3520
|
+
download_result = pull_ollama_model_with_progress(parsed_model, progress_emit=emit_progress)
|
|
3521
|
+
else:
|
|
3522
|
+
download_result = {"provider": "ollama", "model": parsed_model, "cached": True}
|
|
3523
|
+
emit_progress(model_download_progress_payload(
|
|
3524
|
+
"download",
|
|
3525
|
+
"이미 다운로드된 Ollama 모델을 확인했습니다.",
|
|
3526
|
+
percent=100,
|
|
3527
|
+
detail=parsed_model,
|
|
3528
|
+
eta_seconds=0,
|
|
3529
|
+
))
|
|
3530
|
+
elif parsed_provider == "vllm":
|
|
3531
|
+
if not hf_model_ready(parsed_model, "vllm"):
|
|
3532
|
+
download_result = download_hf_model(parsed_model, "vllm", progress_emit=emit_progress)
|
|
3533
|
+
else:
|
|
3534
|
+
download_result = {"provider": "vllm", "model": parsed_model, "cached": True}
|
|
3535
|
+
emit_progress(model_download_progress_payload(
|
|
3536
|
+
"download",
|
|
3537
|
+
"이미 다운로드된 모델을 확인했습니다.",
|
|
3538
|
+
percent=100,
|
|
3539
|
+
detail=parsed_model,
|
|
3540
|
+
eta_seconds=0,
|
|
3541
|
+
))
|
|
3542
|
+
emit_progress(model_download_progress_payload(
|
|
3543
|
+
"server",
|
|
3544
|
+
"vLLM 서버를 시작하는 중입니다.",
|
|
3545
|
+
percent=92,
|
|
3546
|
+
indeterminate=True,
|
|
3547
|
+
))
|
|
3548
|
+
ensure_vllm_server(parsed_model)
|
|
3549
|
+
download_result = {**(download_result or {}), "provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
3550
|
+
elif parsed_provider == "llamacpp":
|
|
3551
|
+
if not hf_model_ready(parsed_model, "llamacpp"):
|
|
3552
|
+
download_result = download_hf_model(parsed_model, "llamacpp", progress_emit=emit_progress)
|
|
3553
|
+
else:
|
|
3554
|
+
download_result = {"provider": "llamacpp", "model": parsed_model, "cached": True}
|
|
3555
|
+
emit_progress(model_download_progress_payload(
|
|
3556
|
+
"download",
|
|
3557
|
+
"이미 다운로드된 GGUF 모델을 확인했습니다.",
|
|
3558
|
+
percent=100,
|
|
3559
|
+
detail=parsed_model,
|
|
3560
|
+
eta_seconds=0,
|
|
3561
|
+
))
|
|
3562
|
+
emit_progress(model_download_progress_payload(
|
|
3563
|
+
"server",
|
|
3564
|
+
"llama.cpp 서버를 시작하는 중입니다.",
|
|
3565
|
+
percent=92,
|
|
3566
|
+
indeterminate=True,
|
|
3567
|
+
))
|
|
3568
|
+
ensure_llamacpp_server(parsed_model)
|
|
3569
|
+
download_result = {**(download_result or {}), "provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
3570
|
+
elif parsed_provider == "lmstudio":
|
|
3571
|
+
emit_progress(model_download_progress_payload(
|
|
3572
|
+
"download",
|
|
3573
|
+
"LM Studio 모델을 확인하는 중입니다.",
|
|
3574
|
+
percent=35,
|
|
3575
|
+
indeterminate=True,
|
|
3576
|
+
))
|
|
3577
|
+
ensured = ensure_lmstudio_model(parsed_model)
|
|
3578
|
+
resolved_model = str(
|
|
3579
|
+
ensured.get("instance_id")
|
|
3580
|
+
or ensured.get("resolved_model")
|
|
3581
|
+
or parsed_model
|
|
3582
|
+
).strip()
|
|
3583
|
+
prepared_model_name = resolved_model
|
|
3584
|
+
prepared_model_id = f"lmstudio:{resolved_model}"
|
|
3585
|
+
download_result = ensured
|
|
3586
|
+
else:
|
|
3587
|
+
emit_progress(model_download_progress_payload(
|
|
3588
|
+
"engine",
|
|
3589
|
+
"모델 연결을 준비하는 중입니다.",
|
|
3590
|
+
percent=30,
|
|
3591
|
+
indeterminate=True,
|
|
3592
|
+
))
|
|
3593
|
+
|
|
3594
|
+
work_result.update({
|
|
3595
|
+
"model_id": prepared_model_id,
|
|
3596
|
+
"parsed_provider": parsed_provider,
|
|
3597
|
+
"parsed_model": prepared_model_name,
|
|
3598
|
+
"install_result": install_result,
|
|
3599
|
+
"download_result": download_result,
|
|
3600
|
+
})
|
|
3601
|
+
work_queue.put({"kind": "done"})
|
|
3602
|
+
except HTTPException as exc:
|
|
3603
|
+
work_queue.put({"kind": "error", "status_code": exc.status_code, "detail": exc.detail})
|
|
3604
|
+
except Exception as exc:
|
|
3605
|
+
logging.exception("model prepare stream worker failed")
|
|
3606
|
+
work_queue.put({"kind": "error", "status_code": 500, "detail": str(exc)[-2000:]})
|
|
3607
|
+
|
|
3608
|
+
worker = threading.Thread(target=blocking_prepare, daemon=True)
|
|
3609
|
+
worker.start()
|
|
3610
|
+
|
|
3611
|
+
while True:
|
|
3612
|
+
item = await asyncio.to_thread(work_queue.get)
|
|
3613
|
+
kind = item.get("kind")
|
|
3614
|
+
if kind == "progress":
|
|
3615
|
+
yield sse_event("progress", item["data"])
|
|
3616
|
+
elif kind == "error":
|
|
3617
|
+
raise HTTPException(
|
|
3618
|
+
status_code=int(item.get("status_code") or 500),
|
|
3619
|
+
detail=item.get("detail") or "모델 준비에 실패했습니다.",
|
|
3620
|
+
)
|
|
3621
|
+
elif kind == "done":
|
|
3622
|
+
break
|
|
3623
|
+
|
|
3624
|
+
prepared_model_id = str(work_result.get("model_id") or model_id)
|
|
3625
|
+
prepared_provider = str(work_result.get("parsed_provider") or parsed_provider)
|
|
3626
|
+
install_result = work_result.get("install_result") or {}
|
|
3627
|
+
download_result = work_result.get("download_result")
|
|
3628
|
+
|
|
3629
|
+
yield sse_event("progress", model_download_progress_payload(
|
|
3630
|
+
"load",
|
|
3631
|
+
"모델을 메모리에 로드하는 중입니다.",
|
|
3632
|
+
percent=96,
|
|
3633
|
+
indeterminate=True,
|
|
3634
|
+
))
|
|
3635
|
+
|
|
3636
|
+
effective_email = (user_email or get_current_user(request) or "").strip()
|
|
3637
|
+
user_api_key = get_user_api_key(effective_email, prepared_provider) if prepared_provider != "local_mlx" else None
|
|
3638
|
+
msg = await router.load_model(
|
|
3639
|
+
prepared_model_id,
|
|
3640
|
+
None,
|
|
3641
|
+
draft_model_id=None,
|
|
3642
|
+
api_key_override=user_api_key,
|
|
3643
|
+
owner=effective_email or None,
|
|
3644
|
+
)
|
|
3645
|
+
result = {
|
|
3646
|
+
"status": "ok",
|
|
3647
|
+
"message": msg,
|
|
3648
|
+
"model": prepared_model_id,
|
|
3649
|
+
"current": router.current_model_id,
|
|
3650
|
+
"engine": prepared_provider,
|
|
3651
|
+
"installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
|
|
3652
|
+
"download": download_result,
|
|
3653
|
+
}
|
|
3654
|
+
yield sse_event("progress", model_download_progress_payload(
|
|
3655
|
+
"done",
|
|
3656
|
+
"모델 준비가 완료되었습니다.",
|
|
3657
|
+
percent=100,
|
|
3658
|
+
eta_seconds=0,
|
|
3659
|
+
))
|
|
3660
|
+
yield sse_event("done", result)
|
|
3661
|
+
|
|
3662
|
+
|
|
3809
3663
|
CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}
|
|
3810
3664
|
CLOUD_VERIFY_TTL_SECONDS = 600
|
|
3811
3665
|
|
|
@@ -3964,6 +3818,38 @@ async def engines_prepare_model(req: PrepareModelRequest, request: Request):
|
|
|
3964
3818
|
)
|
|
3965
3819
|
|
|
3966
3820
|
|
|
3821
|
+
@app.post("/engines/prepare-model/stream")
|
|
3822
|
+
async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
|
|
3823
|
+
require_user(request)
|
|
3824
|
+
|
|
3825
|
+
async def event_stream():
|
|
3826
|
+
try:
|
|
3827
|
+
async for chunk in prepare_and_load_model_stream(
|
|
3828
|
+
req.model,
|
|
3829
|
+
request,
|
|
3830
|
+
engine=req.engine,
|
|
3831
|
+
user_email=req.user_email,
|
|
3832
|
+
):
|
|
3833
|
+
yield chunk
|
|
3834
|
+
except HTTPException as exc:
|
|
3835
|
+
yield sse_event("error", {
|
|
3836
|
+
"status_code": exc.status_code,
|
|
3837
|
+
"detail": exc.detail or "모델 준비에 실패했습니다.",
|
|
3838
|
+
})
|
|
3839
|
+
except Exception as exc:
|
|
3840
|
+
logging.exception("model prepare stream failed")
|
|
3841
|
+
yield sse_event("error", {
|
|
3842
|
+
"status_code": 500,
|
|
3843
|
+
"detail": str(exc)[-1000:] or "모델 준비에 실패했습니다.",
|
|
3844
|
+
})
|
|
3845
|
+
|
|
3846
|
+
return StreamingResponse(
|
|
3847
|
+
event_stream(),
|
|
3848
|
+
media_type="text/event-stream",
|
|
3849
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
3850
|
+
)
|
|
3851
|
+
|
|
3852
|
+
|
|
3967
3853
|
@app.post("/setup/set-api-key")
|
|
3968
3854
|
async def set_api_key(req: SetApiKeyRequest, request: Request):
|
|
3969
3855
|
from llm_router import OPENAI_COMPATIBLE_PROVIDERS
|
|
@@ -4122,14 +4008,14 @@ async def chat(req: ChatRequest, request: Request):
|
|
|
4122
4008
|
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
4123
4009
|
if command == "/clear_all":
|
|
4124
4010
|
result = clear_history(0)
|
|
4125
|
-
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와
|
|
4011
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
4126
4012
|
else:
|
|
4127
4013
|
if req.conversation_id:
|
|
4128
4014
|
result = clear_conversation(req.conversation_id)
|
|
4129
|
-
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와
|
|
4015
|
+
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
4130
4016
|
else:
|
|
4131
4017
|
result = clear_history(0)
|
|
4132
|
-
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와
|
|
4018
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
4133
4019
|
append_audit_event(
|
|
4134
4020
|
"clear_command",
|
|
4135
4021
|
user_email=effective_email,
|
|
@@ -5155,10 +5041,7 @@ async def _phase_verify(
|
|
|
5155
5041
|
ctx.state = AgentState.ROLLBACK
|
|
5156
5042
|
elif next_s == "EXECUTING":
|
|
5157
5043
|
if ctx.retry_count >= max_retry:
|
|
5158
|
-
ctx.final_message =
|
|
5159
|
-
f"최대 재시도({max_retry}회) 초과로 작업을 종료했습니다. "
|
|
5160
|
-
f"마지막 비판: {verdict.get('reason', '(없음)')}"
|
|
5161
|
-
)
|
|
5044
|
+
ctx.final_message = "처리 중 문제가 발생했습니다. 다시 시도해 주세요."
|
|
5162
5045
|
ctx.state = AgentState.FAILED
|
|
5163
5046
|
else:
|
|
5164
5047
|
ctx.retry_count += 1
|
|
@@ -6047,9 +5930,9 @@ async def tools_computer_use_status(request: Request):
|
|
|
6047
5930
|
return _tool_response(computer_status)
|
|
6048
5931
|
|
|
6049
5932
|
|
|
6050
|
-
# ──
|
|
5933
|
+
# ── 내 컴퓨터 API ──────────────────────────────────────────────────────────
|
|
6051
5934
|
|
|
6052
|
-
CU_SYSTEM_PROMPT = """You are Lattice AI
|
|
5935
|
+
CU_SYSTEM_PROMPT = """You are Lattice AI desktop-control agent. You control the Mac desktop using tools.
|
|
6053
5936
|
Prefer non-visual direct actions when possible. Use screenshots only when you must inspect visible UI state or choose screen coordinates.
|
|
6054
5937
|
|
|
6055
5938
|
Available actions:
|
|
@@ -6185,8 +6068,8 @@ async def cu_drag(req: CuDragRequest, request: Request):
|
|
|
6185
6068
|
|
|
6186
6069
|
@app.post("/cu/agent")
|
|
6187
6070
|
async def cu_agent(req: CuAgentRequest, request: Request):
|
|
6188
|
-
"""SSE streaming
|
|
6189
|
-
|
|
6071
|
+
"""SSE streaming desktop-control agent loop."""
|
|
6072
|
+
require_user(request)
|
|
6190
6073
|
async def _stream():
|
|
6191
6074
|
task_lower = (req.task or "").lower()
|
|
6192
6075
|
url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
|
|
@@ -6413,9 +6296,9 @@ _MCP_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|
|
6413
6296
|
"computer_scroll": "Scroll at screen coordinates.",
|
|
6414
6297
|
"computer_move": "Move the mouse to screen coordinates.",
|
|
6415
6298
|
"computer_drag": "Drag from (x1,y1) to (x2,y2).",
|
|
6416
|
-
"computer_status": "Check if Mac
|
|
6299
|
+
"computer_status": "Check if Mac desktop control (pyautogui) is available.",
|
|
6417
6300
|
"chrome_status": "Report Chrome desktop bridge availability.",
|
|
6418
|
-
"computer_use_status": "Report Mac
|
|
6301
|
+
"computer_use_status": "Report Mac desktop-control bridge availability.",
|
|
6419
6302
|
"knowledge_save": "Save a note into the local knowledge garden.",
|
|
6420
6303
|
"knowledge_search": "Search the local knowledge garden.",
|
|
6421
6304
|
"knowledge_tree": "List local knowledge garden markdown files.",
|
|
@@ -6517,10 +6400,9 @@ async def mcp_connector(mcp_id: str, request: Request):
|
|
|
6517
6400
|
@app.post("/mcp/registry/refresh")
|
|
6518
6401
|
async def mcp_registry_refresh(request: Request):
|
|
6519
6402
|
require_user(request)
|
|
6520
|
-
|
|
6521
|
-
_REMOTE_REGISTRY_FETCHED_AT = None
|
|
6403
|
+
mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
|
|
6522
6404
|
registry = await _get_combined_registry()
|
|
6523
|
-
return {"status": "ok", "total": len(registry), "remote": len(_REMOTE_REGISTRY_CACHE)}
|
|
6405
|
+
return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
|
|
6524
6406
|
|
|
6525
6407
|
|
|
6526
6408
|
@app.get("/mcp/claude-code-servers")
|
|
@@ -6681,8 +6563,7 @@ async def skills_list(request: Request):
|
|
|
6681
6563
|
async def skills_marketplace_refresh(request: Request):
|
|
6682
6564
|
"""Skills 마켓플레이스 캐시 강제 갱신"""
|
|
6683
6565
|
require_user(request)
|
|
6684
|
-
|
|
6685
|
-
_SKILLS_MARKETPLACE_FETCHED_AT = None
|
|
6566
|
+
mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
|
|
6686
6567
|
skills = await _fetch_skills_marketplace()
|
|
6687
6568
|
by_author = {}
|
|
6688
6569
|
for s in skills:
|
|
@@ -6725,8 +6606,7 @@ async def plugins_directory(
|
|
|
6725
6606
|
async def plugins_directory_refresh(request: Request):
|
|
6726
6607
|
"""플러그인 디렉터리 캐시 강제 갱신"""
|
|
6727
6608
|
require_user(request)
|
|
6728
|
-
|
|
6729
|
-
_PLUGIN_DIRECTORY_FETCHED_AT = None
|
|
6609
|
+
mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
|
|
6730
6610
|
plugins = await _fetch_plugin_directory()
|
|
6731
6611
|
by_license = {}
|
|
6732
6612
|
for p in plugins:
|
|
@@ -6803,6 +6683,20 @@ def setup_auto_state() -> Dict[str, object]:
|
|
|
6803
6683
|
"preset": auto_setup_preset(profile, recommendation),
|
|
6804
6684
|
}
|
|
6805
6685
|
|
|
6686
|
+
|
|
6687
|
+
def primary_setup_model(recs: Dict[str, object]) -> Optional[Dict[str, object]]:
|
|
6688
|
+
models = recs.get("models") if isinstance(recs, dict) else None
|
|
6689
|
+
if not isinstance(models, list):
|
|
6690
|
+
return None
|
|
6691
|
+
candidates = [
|
|
6692
|
+
item for item in models
|
|
6693
|
+
if isinstance(item, dict) and not item.get("disabled") and (item.get("model_id") or (item.get("action") or {}).get("model_id"))
|
|
6694
|
+
]
|
|
6695
|
+
if not candidates:
|
|
6696
|
+
return None
|
|
6697
|
+
return next((item for item in candidates if item.get("checked")), candidates[0])
|
|
6698
|
+
|
|
6699
|
+
|
|
6806
6700
|
@app.get("/setup/scan")
|
|
6807
6701
|
async def setup_scan(request: Request):
|
|
6808
6702
|
"""환경 감지 및 맞춤 추천 반환."""
|
|
@@ -6810,6 +6704,27 @@ async def setup_scan(request: Request):
|
|
|
6810
6704
|
env = scan_environment()
|
|
6811
6705
|
recs = get_recommendations(env)
|
|
6812
6706
|
zero_config = setup_auto_state()
|
|
6707
|
+
primary_model = primary_setup_model(recs)
|
|
6708
|
+
if primary_model:
|
|
6709
|
+
model_id = primary_model.get("model_id") or (primary_model.get("action") or {}).get("model_id")
|
|
6710
|
+
zero_config.setdefault("recommend", {})["model_id"] = model_id
|
|
6711
|
+
zero_config["recommend"]["runtime"] = "mlx"
|
|
6712
|
+
rationale = [
|
|
6713
|
+
item for item in zero_config["recommend"].get("rationale", [])
|
|
6714
|
+
if not (isinstance(item, str) and item.startswith("RAM ") and "→" in item)
|
|
6715
|
+
]
|
|
6716
|
+
rationale.append(f"실제 다운로드 및 로드 가능한 MLX 모델 → {model_id}")
|
|
6717
|
+
zero_config["recommend"]["rationale"] = rationale
|
|
6718
|
+
if isinstance(zero_config.get("plan"), dict):
|
|
6719
|
+
zero_config["plan"]["steps"] = [{
|
|
6720
|
+
"name": f"weights:{model_id}",
|
|
6721
|
+
"why": "추론에 사용할 모델 가중치",
|
|
6722
|
+
"command": ["huggingface-cli", "download", model_id, "--quiet"],
|
|
6723
|
+
"requires_admin": False,
|
|
6724
|
+
}]
|
|
6725
|
+
if isinstance(zero_config.get("preset"), dict):
|
|
6726
|
+
zero_config["preset"].setdefault("model", {})["id"] = model_id
|
|
6727
|
+
zero_config["preset"]["model"]["runtime"] = "mlx"
|
|
6813
6728
|
env["zero_config"] = zero_config
|
|
6814
6729
|
recs.setdefault("summary", {})["zero_config"] = zero_config["recommend"]
|
|
6815
6730
|
recs["install_plan"] = zero_config["plan"]
|