ltcai 0.1.29 → 0.1.31

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 CHANGED
@@ -47,6 +47,17 @@ from PIL import Image
47
47
 
48
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
49
49
  from knowledge_graph import KnowledgeGraphStore
50
+ from knowledge_graph_api import create_knowledge_graph_router
51
+ from local_knowledge_api import LocalKnowledgeWatcher, create_local_knowledge_router
52
+ import mcp_registry
53
+ from mcp_registry import (
54
+ MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
55
+ _MARKETPLACE_RAW, _MARKETPLACE_API,
56
+ _fetch_remote_mcp_registry, _get_combined_registry,
57
+ _extract_skill_desc, _fetch_plugin_skills,
58
+ _fetch_skills_marketplace, _fetch_plugin_directory,
59
+ _OPEN_LICENSES, install_skill, SKILLS_DIR,
60
+ )
50
61
  from p_reinforce import BRAIN_DIR, PReinforceGardener
51
62
  from setup import get_recommendations, install_stream, open_url, scan_environment
52
63
  from auto_setup import (
@@ -260,7 +271,7 @@ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict
260
271
  append_audit_event("password_migrated_from_plaintext", user_email=email)
261
272
  except Exception as e:
262
273
  logging.warning("audit log failed on password migration: %s", e)
263
- logging.info("Migrated plaintext password to bcrypt hash for %s", email)
274
+ logging.info("Migrated plaintext password to scrypt hash for %s", email)
264
275
  return True
265
276
  return False
266
277
 
@@ -365,6 +376,7 @@ MCP_FILE = DATA_DIR / "mcp_installs.json"
365
376
  AUDIT_FILE = DATA_DIR / "audit_log.json"
366
377
  SSO_FILE = DATA_DIR / "sso_config.json"
367
378
  KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
379
+ LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
368
380
 
369
381
  def _require_graph():
370
382
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -482,17 +494,6 @@ class SkillInstallRequest(BaseModel):
482
494
  plugin: str
483
495
  skill: str
484
496
 
485
- class KnowledgeGraphIngestRequest(BaseModel):
486
- type: str
487
- content: str = ""
488
- role: Optional[str] = None
489
- title: Optional[str] = None
490
- source: Optional[str] = None
491
- conversation_id: Optional[str] = None
492
- user_email: Optional[str] = None
493
- user_nickname: Optional[str] = None
494
- metadata: Optional[Dict] = None
495
-
496
497
  DEFAULT_VPC_CONFIG = {
497
498
  "provider": "AWS",
498
499
  "region": "ap-northeast-2",
@@ -505,782 +506,6 @@ DEFAULT_VPC_CONFIG = {
505
506
  "updated_at": None,
506
507
  }
507
508
 
508
- MCP_REGISTRY = [
509
- {
510
- "id": "presentations",
511
- "name": "Presentations MCP",
512
- "category": "PPT / slides",
513
- "install_mode": "bundled",
514
- "description": "PowerPoint, Google Slides용 발표자료를 만들고 렌더링 검수까지 이어갑니다.",
515
- "keywords": ["ppt", "powerpoint", "slides", "slide", "deck", "presentation", "발표", "피피티", "프레젠테이션", "슬라이드", "제안서"],
516
- "capabilities": ["PPTX 생성", "슬라이드 구조화", "차트 중심 스토리", "렌더링 검수"],
517
- },
518
- {
519
- "id": "documents",
520
- "name": "Documents MCP",
521
- "category": "Docs / reports",
522
- "install_mode": "bundled",
523
- "description": "Word 문서, 보고서, 계약서 초안, 문서 redline 및 시각 검수를 처리합니다.",
524
- "keywords": ["docx", "word", "docs", "document", "report", "문서", "보고서", "계약서", "기획서", "레포트"],
525
- "capabilities": ["DOCX 생성", "문서 편집", "코멘트/수정", "PDF 렌더 확인"],
526
- },
527
- {
528
- "id": "spreadsheets",
529
- "name": "Spreadsheets MCP",
530
- "category": "Sheets / data",
531
- "install_mode": "bundled",
532
- "description": "Excel/CSV/Google Sheets형 데이터 분석, 수식, 표, 차트를 만듭니다.",
533
- "keywords": ["xlsx", "excel", "spreadsheet", "sheet", "csv", "data", "엑셀", "스프레드시트", "표", "데이터", "차트"],
534
- "capabilities": ["XLSX 생성", "수식/서식", "데이터 분석", "차트"],
535
- },
536
- {
537
- "id": "browser",
538
- "name": "Browser MCP",
539
- "category": "Web / dashboard QA",
540
- "install_mode": "bundled",
541
- "description": "로컬 웹앱, 대시보드, 폼, 페이지 렌더링을 브라우저에서 확인합니다.",
542
- "keywords": ["dashboard", "web", "website", "frontend", "ui", "browser", "localhost", "대시보드", "웹", "사이트", "프론트", "화면", "검수"],
543
- "capabilities": ["로컬 페이지 열기", "스크린샷", "DOM 검사", "UI 회귀 확인"],
544
- },
545
- {
546
- "id": "chrome",
547
- "name": "Chrome MCP",
548
- "category": "Browser / authenticated web",
549
- "install_mode": "connector",
550
- "connector_url": "/mcp/connectors/chrome",
551
- "external_url": "codex://plugins/chrome",
552
- "description": "사용자 Chrome 프로필, 로그인 세션, 기존 탭을 활용하는 브라우저 자동화 브리지입니다.",
553
- "keywords": ["chrome", "browser", "cookie", "session", "login", "크롬", "브라우저", "로그인", "세션", "탭"],
554
- "capabilities": ["Chrome 탭 확인", "로그인 세션 활용", "프로필 기반 웹 자동화"],
555
- },
556
- {
557
- "id": "computer-use",
558
- "name": "내 컴퓨터 MCP",
559
- "category": "Desktop / Mac UI",
560
- "install_mode": "connector",
561
- "connector_url": "/mcp/connectors/computer-use",
562
- "external_url": "codex://plugins/computer-use",
563
- "description": "사용자의 허용을 받아 이 컴퓨터의 파일, 화면, 앱 작업을 돕는 브리지입니다.",
564
- "keywords": ["computer use", "desktop", "mac", "click", "type", "scroll", "내 컴퓨터", "컴퓨터", "맥", "앱", "클릭", "타이핑"],
565
- "capabilities": ["Mac 앱 UI 조작", "스크린샷 기반 상태 확인", "클릭/입력/스크롤"],
566
- },
567
- {
568
- "id": "filesystem",
569
- "name": "Workspace Files MCP",
570
- "category": "Files / coding",
571
- "install_mode": "builtin",
572
- "description": "프로젝트 파일 읽기/쓰기, 검색, 코드 생성, 로컬 preview URL 생성을 수행합니다.",
573
- "keywords": ["code", "coding", "file", "folder", "project", "build", "deploy", "구현", "코드", "파일", "폴더", "프로젝트", "빌드", "배포"],
574
- "capabilities": ["파일 생성", "코드 검색", "빌드 스크립트", "배포 스크립트"],
575
- },
576
- {
577
- "id": "google-drive",
578
- "name": "Google Drive Connector",
579
- "category": "File sharing",
580
- "install_mode": "connector",
581
- "connector_url": "/mcp/connectors/google-drive",
582
- "external_url": "https://chatgpt.com/connectors",
583
- "description": "Drive/Docs/Sheets/Slides 파일 공유, 검색, 협업 워크플로에 사용합니다.",
584
- "keywords": ["share", "sharing", "drive", "google drive", "file share", "공유", "파일공유", "드라이브", "구글드라이브", "협업"],
585
- "capabilities": ["파일 공유", "Drive 검색", "Google Docs/Sheets/Slides 연결"],
586
- },
587
- {
588
- "id": "github",
589
- "name": "GitHub Connector",
590
- "category": "Code hosting",
591
- "install_mode": "connector",
592
- "connector_url": "/mcp/connectors/github",
593
- "external_url": "https://github.com/apps",
594
- "description": "저장소, 이슈, PR, CI 확인과 코드 배포 워크플로를 연결합니다.",
595
- "keywords": ["github", "repo", "repository", "pr", "pull request", "issue", "ci", "깃허브", "저장소", "이슈", "배포"],
596
- "capabilities": ["PR 확인", "이슈 탐색", "CI 확인", "릴리즈 준비"],
597
- },
598
- {
599
- "id": "slack",
600
- "name": "Slack Connector",
601
- "category": "Team sharing",
602
- "install_mode": "connector",
603
- "connector_url": "/mcp/connectors/slack",
604
- "external_url": "https://chatgpt.com/connectors",
605
- "description": "팀 채널에 결과 공유, 논의 요약, 알림 워크플로를 연결합니다.",
606
- "keywords": ["slack", "message", "team", "notify", "공유", "알림", "메시지", "슬랙", "팀"],
607
- "capabilities": ["채널 공유", "메시지 작성", "협업 알림"],
608
- },
609
- {
610
- "id": "obsidian-memory",
611
- "name": "Obsidian Memory Vault",
612
- "category": "Memory / knowledge",
613
- "install_mode": "builtin",
614
- "description": "Lattice AI의 장기 기억을 Obsidian 호환 Markdown vault에 저장하고 검색합니다.",
615
- "keywords": ["memory", "remember", "obsidian", "vault", "knowledge", "기억", "메모리", "옵시디언", "지식", "노트"],
616
- "capabilities": ["Markdown vault 저장", "장기 기억 검색", "Obsidian URI 힌트", "프로젝트 로그"],
617
- },
618
- {
619
- "id": "voice-whisper",
620
- "name": "Voice STT (Whisper Local)",
621
- "category": "Voice / speech-to-text",
622
- "install_mode": "pip",
623
- "pip_packages": ["openai-whisper"],
624
- "description": "로컬 음성 인식(STT) 파이프라인용 Whisper 런타임을 설치합니다.",
625
- "keywords": ["voice", "speech", "stt", "whisper", "audio", "음성", "인식", "자막", "전사"],
626
- "capabilities": ["로컬 STT 런타임", "오디오 전사 워크플로 준비"],
627
- },
628
- {
629
- "id": "voice-speechrecognition",
630
- "name": "Voice STT (SpeechRecognition)",
631
- "category": "Voice / speech-to-text",
632
- "install_mode": "pip",
633
- "pip_packages": ["SpeechRecognition"],
634
- "description": "가벼운 음성 인식 실험용 SpeechRecognition 패키지를 설치합니다.",
635
- "keywords": ["voice", "speech", "recognition", "stt", "microphone", "음성", "마이크", "받아쓰기"],
636
- "capabilities": ["STT 파이썬 패키지", "마이크 입력 인식 실험"],
637
- },
638
- {
639
- "id": "audio-pydub",
640
- "name": "Audio Processing (PyDub)",
641
- "category": "Voice / audio processing",
642
- "install_mode": "pip",
643
- "pip_packages": ["pydub"],
644
- "description": "오디오 파일 분할/정규화/포맷 변환 워크플로용 패키지를 설치합니다.",
645
- "keywords": ["audio", "pydub", "wav", "mp3", "전처리", "오디오", "변환"],
646
- "capabilities": ["오디오 전처리", "세그먼트 분할", "포맷 변환"],
647
- },
648
- {
649
- "id": "threejs-workflow",
650
- "name": "3D Workflow (Three.js)",
651
- "category": "3D / interactive web",
652
- "install_mode": "bundled",
653
- "description": "브라우저 검수 + 코드 생성 흐름으로 Three.js 기반 3D 화면을 구현/검증합니다.",
654
- "keywords": ["3d", "three", "threejs", "webgl", "scene", "3차원", "쓰리제이에스", "렌더링"],
655
- "capabilities": ["Three.js 코드 생성", "3D 씬 검수", "브라우저 상호작용 테스트"],
656
- },
657
- {
658
- "id": "figma",
659
- "name": "Figma Connector",
660
- "category": "Design / handoff",
661
- "install_mode": "connector",
662
- "connector_url": "/mcp/connectors/figma",
663
- "external_url": "https://chatgpt.com/connectors",
664
- "description": "디자인 파일 참조, 컴포넌트 규칙 확인, 구현 핸드오프를 연결합니다.",
665
- "keywords": ["figma", "design", "handoff", "컴포넌트", "디자인", "피그마"],
666
- "capabilities": ["디자인 참조", "핸드오프 워크플로", "컴포넌트 맵핑"],
667
- },
668
- {
669
- "id": "notion",
670
- "name": "Notion Connector",
671
- "category": "Knowledge / docs",
672
- "install_mode": "connector",
673
- "connector_url": "/mcp/connectors/notion",
674
- "external_url": "https://chatgpt.com/connectors",
675
- "description": "노션 문서/DB와 연동해 구현 노트, 회의 요약, 지식 관리 워크플로를 만듭니다.",
676
- "keywords": ["notion", "wiki", "docs", "database", "노션", "위키", "문서", "지식관리"],
677
- "capabilities": ["페이지 검색", "문서 작성 보조", "지식 동기화"],
678
- },
679
- {
680
- "id": "linear",
681
- "name": "Linear Connector",
682
- "category": "Project / issue tracking",
683
- "install_mode": "connector",
684
- "connector_url": "/mcp/connectors/linear",
685
- "external_url": "https://chatgpt.com/connectors",
686
- "description": "이슈 상태 확인, 우선순위 정리, 릴리즈 태스크 연결에 사용합니다.",
687
- "keywords": ["linear", "issue", "project", "sprint", "이슈", "태스크", "프로젝트"],
688
- "capabilities": ["이슈 조회", "작업 우선순위", "릴리즈 트래킹"],
689
- },
690
- {
691
- "id": "gmail",
692
- "name": "Gmail Connector",
693
- "category": "Communication / email",
694
- "install_mode": "connector",
695
- "connector_url": "/mcp/connectors/gmail",
696
- "external_url": "https://chatgpt.com/connectors",
697
- "description": "이메일 요약, 답장 초안, 업무 메일 정리에 사용합니다.",
698
- "keywords": ["gmail", "email", "mail", "inbox", "메일", "지메일", "이메일"],
699
- "capabilities": ["메일 검색", "요약", "답장 초안"],
700
- },
701
- {
702
- "id": "google-calendar",
703
- "name": "Google Calendar Connector",
704
- "category": "Scheduling / calendar",
705
- "install_mode": "connector",
706
- "connector_url": "/mcp/connectors/google-calendar",
707
- "external_url": "https://chatgpt.com/connectors",
708
- "description": "일정 확인, 미팅 슬롯 탐색, 일정 생성 워크플로를 연결합니다.",
709
- "keywords": ["calendar", "schedule", "meeting", "구글캘린더", "일정", "미팅"],
710
- "capabilities": ["일정 조회", "빈 시간 탐색", "이벤트 생성"],
711
- },
712
- {
713
- "id": "outlook-email",
714
- "name": "Outlook Email Connector",
715
- "category": "Communication / email",
716
- "install_mode": "connector",
717
- "connector_url": "/mcp/connectors/outlook-email",
718
- "external_url": "https://chatgpt.com/connectors",
719
- "description": "Outlook 메일함 연동, 메일 검색/초안/요약 워크플로를 제공합니다.",
720
- "keywords": ["outlook", "email", "mail", "아웃룩", "메일"],
721
- "capabilities": ["메일 검색", "요약", "초안 작성"],
722
- },
723
- {
724
- "id": "outlook-calendar",
725
- "name": "Outlook Calendar Connector",
726
- "category": "Scheduling / calendar",
727
- "install_mode": "connector",
728
- "connector_url": "/mcp/connectors/outlook-calendar",
729
- "external_url": "https://chatgpt.com/connectors",
730
- "description": "Outlook 일정 연동으로 회의 준비/시간 조율 작업을 진행합니다.",
731
- "keywords": ["outlook calendar", "calendar", "schedule", "아웃룩 캘린더", "일정"],
732
- "capabilities": ["일정 조회", "회의 준비", "시간 조율"],
733
- },
734
- {
735
- "id": "teams",
736
- "name": "Microsoft Teams Connector",
737
- "category": "Team collaboration",
738
- "install_mode": "connector",
739
- "connector_url": "/mcp/connectors/teams",
740
- "external_url": "https://chatgpt.com/connectors",
741
- "description": "팀 대화 컨텍스트 기반 업무 자동화와 협업 공유를 지원합니다.",
742
- "keywords": ["teams", "microsoft teams", "chat", "협업", "팀즈"],
743
- "capabilities": ["팀 대화 공유", "협업 흐름 연결"],
744
- },
745
- {
746
- "id": "sharepoint",
747
- "name": "SharePoint Connector",
748
- "category": "Enterprise files",
749
- "install_mode": "connector",
750
- "connector_url": "/mcp/connectors/sharepoint",
751
- "external_url": "https://chatgpt.com/connectors",
752
- "description": "SharePoint 문서 저장소를 검색/참조하는 엔터프라이즈 워크플로를 지원합니다.",
753
- "keywords": ["sharepoint", "document", "enterprise", "문서", "셰어포인트"],
754
- "capabilities": ["문서 검색", "사내 파일 참조"],
755
- },
756
- {
757
- "id": "canva",
758
- "name": "Canva Connector",
759
- "category": "Design / visuals",
760
- "install_mode": "connector",
761
- "connector_url": "/mcp/connectors/canva",
762
- "external_url": "https://chatgpt.com/connectors",
763
- "description": "디자인 템플릿 기반 이미지/슬라이드 작업을 연동합니다.",
764
- "keywords": ["canva", "design", "poster", "card", "캔바", "디자인"],
765
- "capabilities": ["디자인 템플릿", "이미지 제작 워크플로"],
766
- },
767
- # ── 데이터베이스 ─────────────────────────────────────────────────────────
768
- {
769
- "id": "mcp-postgres",
770
- "name": "PostgreSQL MCP",
771
- "category": "Database",
772
- "install_mode": "npm",
773
- "package": "@modelcontextprotocol/server-postgres",
774
- "description": "PostgreSQL 데이터베이스에 연결해 쿼리 실행, 스키마 탐색, 데이터 분석을 수행합니다.",
775
- "keywords": ["postgres", "postgresql", "database", "sql", "db", "데이터베이스", "쿼리"],
776
- "capabilities": ["SQL 쿼리 실행", "스키마 탐색", "테이블 분석"],
777
- "env_vars": [{"name": "POSTGRES_CONNECTION_STRING", "description": "postgresql://user:pass@host:5432/db"}],
778
- },
779
- {
780
- "id": "mcp-sqlite",
781
- "name": "SQLite MCP",
782
- "category": "Database",
783
- "install_mode": "npm",
784
- "package": "@modelcontextprotocol/server-sqlite",
785
- "description": "로컬 SQLite 파일에 쿼리를 실행하고 데이터를 탐색합니다.",
786
- "keywords": ["sqlite", "database", "sql", "local", "로컬", "데이터베이스"],
787
- "capabilities": ["SQLite 쿼리", "테이블 탐색", "데이터 집계"],
788
- "env_vars": [{"name": "SQLITE_DB_PATH", "description": "/path/to/database.db"}],
789
- },
790
- # ── 검색 / 웹 ────────────────────────────────────────────────────────────
791
- {
792
- "id": "mcp-brave-search",
793
- "name": "Brave Search MCP",
794
- "category": "Search / web",
795
- "install_mode": "npm",
796
- "package": "@modelcontextprotocol/server-brave-search",
797
- "description": "Brave Search API로 실시간 웹 검색 결과를 가져옵니다.",
798
- "keywords": ["search", "web", "brave", "websearch", "검색", "웹검색"],
799
- "capabilities": ["실시간 웹 검색", "뉴스 검색", "이미지 검색"],
800
- "env_vars": [{"name": "BRAVE_API_KEY", "description": "Brave Search API 키 (search.brave.com)"}],
801
- },
802
- {
803
- "id": "mcp-tavily",
804
- "name": "Tavily Search MCP",
805
- "category": "Search / web",
806
- "install_mode": "npm",
807
- "package": "tavily-mcp",
808
- "description": "AI 최적화 웹 검색 엔진 Tavily로 고품질 검색 결과를 가져옵니다.",
809
- "keywords": ["search", "tavily", "ai search", "검색", "AI검색"],
810
- "capabilities": ["AI 최적화 검색", "요약 검색 결과"],
811
- "env_vars": [{"name": "TAVILY_API_KEY", "description": "app.tavily.com에서 발급"}],
812
- },
813
- {
814
- "id": "mcp-puppeteer",
815
- "name": "Puppeteer MCP",
816
- "category": "Browser automation",
817
- "install_mode": "npm",
818
- "package": "@modelcontextprotocol/server-puppeteer",
819
- "description": "Puppeteer로 브라우저를 제어하고 웹 스크래핑, 스크린샷, 자동화를 수행합니다.",
820
- "keywords": ["puppeteer", "browser", "scraping", "screenshot", "automation", "스크래핑", "자동화"],
821
- "capabilities": ["웹 스크래핑", "스크린샷", "폼 자동화", "클릭/입력"],
822
- },
823
- # ── 배포 / 인프라 ─────────────────────────────────────────────────────────
824
- {
825
- "id": "mcp-vercel",
826
- "name": "Vercel MCP",
827
- "category": "Deployment",
828
- "install_mode": "npm",
829
- "package": "@vercel/mcp-adapter",
830
- "description": "Vercel 프로젝트 배포 상태 확인, 로그 조회, 환경 변수 관리를 수행합니다.",
831
- "keywords": ["vercel", "deploy", "deployment", "serverless", "배포", "버셀"],
832
- "capabilities": ["배포 상태 확인", "로그 조회", "환경 변수 관리"],
833
- "env_vars": [{"name": "VERCEL_API_TOKEN", "description": "Vercel 계정 토큰"}],
834
- },
835
- {
836
- "id": "mcp-cloudflare",
837
- "name": "Cloudflare MCP",
838
- "category": "Deployment / CDN",
839
- "install_mode": "npm",
840
- "package": "@cloudflare/mcp-server-cloudflare",
841
- "description": "Cloudflare Workers, KV, R2, D1 등 Cloudflare 서비스를 관리합니다.",
842
- "keywords": ["cloudflare", "workers", "cdn", "kv", "r2", "클라우드플레어"],
843
- "capabilities": ["Workers 배포", "KV/R2 관리", "DNS 조회", "D1 쿼리"],
844
- "env_vars": [{"name": "CLOUDFLARE_API_TOKEN", "description": "Cloudflare API 토큰"}],
845
- },
846
- {
847
- "id": "mcp-docker",
848
- "name": "Docker MCP",
849
- "category": "Infrastructure",
850
- "install_mode": "npm",
851
- "package": "docker-mcp",
852
- "description": "Docker 컨테이너 목록 조회, 실행/중지, 로그 확인을 수행합니다.",
853
- "keywords": ["docker", "container", "devops", "도커", "컨테이너", "인프라"],
854
- "capabilities": ["컨테이너 관리", "이미지 조회", "로그 확인", "실행/중지"],
855
- },
856
- # ── SaaS / 결제 ───────────────────────────────────────────────────────────
857
- {
858
- "id": "mcp-stripe",
859
- "name": "Stripe MCP",
860
- "category": "Payments",
861
- "install_mode": "npm",
862
- "package": "@stripe/agent-toolkit",
863
- "description": "Stripe 결제, 고객, 구독, 인보이스를 조회하고 관리합니다.",
864
- "keywords": ["stripe", "payment", "billing", "subscription", "결제", "스트라이프"],
865
- "capabilities": ["결제 조회", "고객 관리", "구독 확인", "인보이스"],
866
- "env_vars": [{"name": "STRIPE_SECRET_KEY", "description": "Stripe Secret Key (sk_...)"}],
867
- },
868
- {
869
- "id": "mcp-supabase",
870
- "name": "Supabase MCP",
871
- "category": "Database / BaaS",
872
- "install_mode": "npm",
873
- "package": "@supabase/mcp-server-supabase",
874
- "description": "Supabase 프로젝트의 DB 쿼리, Auth 관리, Storage 파일 접근을 수행합니다.",
875
- "keywords": ["supabase", "database", "auth", "storage", "supabase", "슈퍼베이스"],
876
- "capabilities": ["DB 쿼리", "Auth 사용자 조회", "Storage 파일 관리"],
877
- "env_vars": [
878
- {"name": "SUPABASE_URL", "description": "https://xxx.supabase.co"},
879
- {"name": "SUPABASE_SERVICE_ROLE_KEY", "description": "service_role 키"},
880
- ],
881
- },
882
- {
883
- "id": "mcp-hubspot",
884
- "name": "HubSpot MCP",
885
- "category": "CRM / marketing",
886
- "install_mode": "npm",
887
- "package": "@hubspot/mcp-server",
888
- "description": "HubSpot CRM의 연락처, 딜, 캠페인 데이터를 조회하고 분석합니다.",
889
- "keywords": ["hubspot", "crm", "marketing", "sales", "허브스팟", "CRM"],
890
- "capabilities": ["연락처 조회", "딜 파이프라인", "캠페인 분석"],
891
- "env_vars": [{"name": "HUBSPOT_ACCESS_TOKEN", "description": "HubSpot Private App 토큰"}],
892
- },
893
- # ── AI / 메모리 ───────────────────────────────────────────────────────────
894
- {
895
- "id": "mcp-memory",
896
- "name": "Memory MCP (공식)",
897
- "category": "Memory / knowledge",
898
- "install_mode": "npm",
899
- "package": "@modelcontextprotocol/server-memory",
900
- "description": "대화 간 지속 메모리를 저장하고 검색하는 공식 MCP 서버입니다.",
901
- "keywords": ["memory", "remember", "knowledge", "기억", "메모리", "지식"],
902
- "capabilities": ["장기 기억 저장", "메모리 검색", "엔티티 추적"],
903
- },
904
- {
905
- "id": "mcp-sequential-thinking",
906
- "name": "Sequential Thinking MCP",
907
- "category": "AI / reasoning",
908
- "install_mode": "npm",
909
- "package": "@modelcontextprotocol/server-sequential-thinking",
910
- "description": "복잡한 문제를 단계별로 분해해 추론하는 사고 흐름 도구입니다.",
911
- "keywords": ["reasoning", "thinking", "chain of thought", "추론", "사고"],
912
- "capabilities": ["단계별 추론", "문제 분해", "사고 흐름 추적"],
913
- },
914
- # ── 커뮤니케이션 ──────────────────────────────────────────────────────────
915
- {
916
- "id": "mcp-discord",
917
- "name": "Discord MCP",
918
- "category": "Communication",
919
- "install_mode": "npm",
920
- "package": "discord-mcp",
921
- "description": "Discord 서버 채널 메시지 전송, 읽기, 관리 자동화를 수행합니다.",
922
- "keywords": ["discord", "message", "channel", "디스코드", "메시지", "알림"],
923
- "capabilities": ["메시지 전송", "채널 읽기", "알림 자동화"],
924
- "env_vars": [{"name": "DISCORD_BOT_TOKEN", "description": "Discord Bot 토큰"}],
925
- },
926
- {
927
- "id": "mcp-telegram",
928
- "name": "Telegram MCP",
929
- "category": "Communication",
930
- "install_mode": "npm",
931
- "package": "telegram-mcp",
932
- "description": "Telegram 봇을 통한 메시지 전송, 수신, 알림 자동화를 수행합니다.",
933
- "keywords": ["telegram", "bot", "message", "텔레그램", "봇", "메시지"],
934
- "capabilities": ["메시지 전송/수신", "알림 자동화", "그룹 관리"],
935
- "env_vars": [{"name": "TELEGRAM_BOT_TOKEN", "description": "BotFather에서 발급한 토큰"}],
936
- },
937
- # ── 개발 도구 ─────────────────────────────────────────────────────────────
938
- {
939
- "id": "mcp-everything",
940
- "name": "Everything MCP (테스트)",
941
- "category": "Developer tools",
942
- "install_mode": "npm",
943
- "package": "@modelcontextprotocol/server-everything",
944
- "description": "MCP 연결 테스트용 모든 기능이 포함된 데모 서버입니다.",
945
- "keywords": ["test", "demo", "everything", "테스트", "개발"],
946
- "capabilities": ["MCP 기능 테스트", "프로토타입"],
947
- },
948
- ]
949
-
950
- # ── Remote MCP Registry (registry.modelcontextprotocol.io) ───────────────────
951
- _REMOTE_REGISTRY_CACHE: List[Dict] = []
952
- _REMOTE_REGISTRY_FETCHED_AT: Optional[datetime] = None
953
- _REMOTE_REGISTRY_TTL = timedelta(hours=1)
954
- _REMOTE_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers"
955
- _LOCAL_IDS = {e["id"] for e in MCP_REGISTRY}
956
-
957
- async def _fetch_remote_mcp_registry() -> List[Dict]:
958
- global _REMOTE_REGISTRY_CACHE, _REMOTE_REGISTRY_FETCHED_AT
959
- now = datetime.now()
960
- if _REMOTE_REGISTRY_FETCHED_AT and (now - _REMOTE_REGISTRY_FETCHED_AT) < _REMOTE_REGISTRY_TTL:
961
- return _REMOTE_REGISTRY_CACHE
962
- try:
963
- result: List[Dict] = []
964
- cursor = None
965
- async with httpx.AsyncClient(timeout=10.0) as client:
966
- while True:
967
- params: Dict = {"limit": 100}
968
- if cursor:
969
- params["cursor"] = cursor
970
- resp = await client.get(_REMOTE_REGISTRY_URL, params=params)
971
- resp.raise_for_status()
972
- data = resp.json()
973
- for s in data.get("servers", []):
974
- srv = s["server"]
975
- meta = s.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
976
- if not meta.get("isLatest", True):
977
- continue
978
- pkg = next(
979
- (p for p in srv.get("packages", [])
980
- if p.get("transport", {}).get("type") == "stdio"
981
- and p.get("registryType") in ("npm", "pypi")),
982
- None,
983
- )
984
- if not pkg:
985
- continue
986
- entry_id = srv["name"].replace("/", "-").replace(".", "-")
987
- if entry_id in _LOCAL_IDS:
988
- continue
989
- result.append({
990
- "id": entry_id,
991
- "name": srv.get("title") or srv["name"],
992
- "category": "MCP Registry",
993
- "install_mode": pkg["registryType"],
994
- "package": pkg["identifier"],
995
- "package_version": pkg.get("version"),
996
- "description": srv.get("description", ""),
997
- "keywords": [],
998
- "capabilities": [],
999
- "source": "registry",
1000
- "homepage": (srv.get("repository") or {}).get("url"),
1001
- })
1002
- cursor = data.get("nextCursor")
1003
- if not cursor:
1004
- break
1005
- _REMOTE_REGISTRY_CACHE = result
1006
- _REMOTE_REGISTRY_FETCHED_AT = now
1007
- logging.info("Fetched %d stdio MCP servers from remote registry", len(result))
1008
- except Exception as e:
1009
- logging.warning("Failed to fetch remote MCP registry: %s", e)
1010
- return _REMOTE_REGISTRY_CACHE
1011
-
1012
- async def _get_combined_registry() -> List[Dict]:
1013
- remote = await _fetch_remote_mcp_registry()
1014
- return MCP_REGISTRY + remote
1015
-
1016
- # ── Anthropic Skills Marketplace (Apache 2.0) ─────────────────────────────────
1017
- _MARKETPLACE_RAW = "https://raw.githubusercontent.com/anthropics/claude-plugins-official/main"
1018
- _MARKETPLACE_API = "https://api.github.com/repos/anthropics/claude-plugins-official/contents"
1019
-
1020
- # 검증된 서드파티 skills 소스 (Apache-2.0 / MIT)
1021
- _THIRD_PARTY_SKILL_SOURCES: List[Dict] = [
1022
- {
1023
- "plugin": "adobe-for-creativity", "author": "Adobe", "license": "Apache-2.0",
1024
- "repo": "adobe/skills", "branch": "main",
1025
- "plugin_path": "plugins/creative-cloud/adobe-for-creativity",
1026
- "category": "design",
1027
- },
1028
- {
1029
- "plugin": "airtable", "author": "Airtable", "license": "MIT",
1030
- "repo": "Airtable/skills", "branch": "main",
1031
- "plugin_path": "plugins/airtable",
1032
- "category": "productivity",
1033
- },
1034
- {
1035
- "plugin": "auth0", "author": "Auth0", "license": "Apache-2.0",
1036
- "repo": "auth0/agent-skills", "branch": "main",
1037
- "plugin_path": "plugins/auth0",
1038
- "category": "security",
1039
- },
1040
- {
1041
- "plugin": "expo", "author": "Expo", "license": "MIT",
1042
- "repo": "expo/skills", "branch": "main",
1043
- "plugin_path": "plugins/expo",
1044
- "category": "development",
1045
- },
1046
- {
1047
- "plugin": "logfire", "author": "Pydantic", "license": "MIT",
1048
- "repo": "pydantic/skills", "branch": "main",
1049
- "plugin_path": "plugins/logfire",
1050
- "category": "monitoring",
1051
- },
1052
- ]
1053
-
1054
- # 검증된 레포 라이선스 맵 (GitHub API 없이 빠르게 조회)
1055
- _KNOWN_REPO_LICENSES: Dict[str, str] = {
1056
- # Apache-2.0
1057
- "adobe/skills": "Apache-2.0", "awslabs/agent-plugins": "Apache-2.0",
1058
- "auth0/agent-skills": "Apache-2.0", "aws/agent-toolkit-for-aws": "Apache-2.0",
1059
- "carta/plugins": "Apache-2.0", "circlefin/skills": "Apache-2.0",
1060
- "clickhouse/clickhouse-docs": "Apache-2.0", "cloudflare/agents": "Apache-2.0",
1061
- "cockroachdb/claude-code": "Apache-2.0", "codspeed-hq/codspeed-claude": "Apache-2.0",
1062
- "DataDog/datadog-claude-code": "Apache-2.0", "datahub-project/datahub-skills": "Apache-2.0",
1063
- "neondatabase/agent-skills": "Apache-2.0", "PagerDuty/pd-ai-agents-plugins": "Apache-2.0",
1064
- "getpostman/postman-mcp-server": "Apache-2.0", "qdrant/qdrant-skills": "Apache-2.0",
1065
- "rootlyhq/rootly-plugins": "Apache-2.0", "snowflake-labs/snowflake-claude": "Apache-2.0",
1066
- "sumup/sumup-claude": "Apache-2.0", "zilliz-labs/zilliz-skills": "Apache-2.0",
1067
- "mercadopago/mercadopago-claude-marketplace": "Apache-2.0",
1068
- # MIT
1069
- "Airtable/skills": "MIT", "endorlabs/ai-plugins": "MIT",
1070
- "apollographql/apollo-claude-skills": "MIT", "appwrite/skills": "MIT",
1071
- "atlan-inc/claude-code-skills": "MIT", "boxer/boxerbox": "MIT",
1072
- "buildkite/claude-code": "MIT", "coderabbitai/coderabbit-skills": "MIT",
1073
- "CrowdStrike/crowdstrike-skills": "MIT", "microsoft/Dataverse-skills": "MIT",
1074
- "duckdb/duckdb-skills": "MIT", "expo/skills": "MIT",
1075
- "intercom/intercom-skills": "MIT", "pydantic/skills": "MIT",
1076
- "mapbox/mapbox-skills": "MIT", "mintlify/mintlify-skills": "MIT",
1077
- "miroapp/miro-ai": "MIT", "netlify/netlify-skills": "MIT",
1078
- "pinecone-io/pinecone-skills": "MIT", "railwayapp/railway-skills": "MIT",
1079
- "resend/resend-skills": "MIT", "sanity-io/sanity-skills": "MIT",
1080
- "getsentry/sentry-ai-skills": "MIT", "Shopify/liquid-skills": "MIT",
1081
- "slackapi/slack-skills": "MIT", "stripe/stripe-skills": "MIT",
1082
- "twilio-labs/twilio-skills": "MIT", "workos/workos-skills": "MIT",
1083
- "zoom/zoom-skills": "MIT", "aws-samples/sample-claude-code-plugins-for-startups": "MIT-0",
1084
- }
1085
-
1086
- _SKILLS_MARKETPLACE_CACHE: List[Dict] = []
1087
- _SKILLS_MARKETPLACE_FETCHED_AT: Optional[datetime] = None
1088
- _SKILLS_MARKETPLACE_TTL = timedelta(hours=1)
1089
-
1090
- def _extract_skill_desc(skill_md: str, fallback: str) -> str:
1091
- for line in skill_md.splitlines():
1092
- if line.startswith("description:"):
1093
- return line.split(":", 1)[1].strip()
1094
- return fallback
1095
-
1096
- async def _fetch_plugin_skills(client: httpx.AsyncClient, source: Dict) -> List[Dict]:
1097
- """단일 소스에서 skill 목록을 fetch해 반환"""
1098
- repo, branch, plugin_path = source["repo"], source["branch"], source["plugin_path"]
1099
- raw_base = f"https://raw.githubusercontent.com/{repo}/{branch}"
1100
- api_base = f"https://api.github.com/repos/{repo}/contents"
1101
- homepage_base = f"https://github.com/{repo}/tree/{branch}"
1102
-
1103
- dir_resp = await client.get(f"{api_base}/{plugin_path}/skills")
1104
- if dir_resp.status_code != 200:
1105
- return []
1106
- skill_dirs = [f["name"] for f in dir_resp.json() if f["type"] == "dir"]
1107
-
1108
- skills: List[Dict] = []
1109
- for skill_name in skill_dirs:
1110
- skill_md_url = f"{raw_base}/{plugin_path}/skills/{skill_name}/SKILL.md"
1111
- sm_resp = await client.get(skill_md_url)
1112
- if sm_resp.status_code != 200:
1113
- continue
1114
- skills.append({
1115
- "plugin": source["plugin"],
1116
- "skill": skill_name,
1117
- "category": source.get("category", "development"),
1118
- "description": _extract_skill_desc(sm_resp.text, source.get("description", "")),
1119
- "skill_md_url": skill_md_url,
1120
- "homepage": f"{homepage_base}/{plugin_path}/skills/{skill_name}",
1121
- "license": source["license"],
1122
- "author": source["author"],
1123
- })
1124
- return skills
1125
-
1126
- async def _fetch_skills_marketplace() -> List[Dict]:
1127
- global _SKILLS_MARKETPLACE_CACHE, _SKILLS_MARKETPLACE_FETCHED_AT
1128
- now = datetime.now()
1129
- if _SKILLS_MARKETPLACE_FETCHED_AT and (now - _SKILLS_MARKETPLACE_FETCHED_AT) < _SKILLS_MARKETPLACE_TTL:
1130
- return _SKILLS_MARKETPLACE_CACHE
1131
- try:
1132
- result: List[Dict] = []
1133
- async with httpx.AsyncClient(timeout=15.0) as client:
1134
- # ── Anthropic 공식 skills (Apache-2.0) ──────────────────────────
1135
- mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
1136
- mp_resp.raise_for_status()
1137
- marketplace_json = mp_resp.json()
1138
- anthropic_plugins = [
1139
- p for p in marketplace_json.get("plugins", [])
1140
- if (p.get("author") or {}).get("name") == "Anthropic"
1141
- and isinstance(p.get("source"), str)
1142
- and p["source"].startswith("./")
1143
- ]
1144
- for plugin in anthropic_plugins:
1145
- plugin_path = plugin["source"].lstrip("./")
1146
- result.extend(await _fetch_plugin_skills(client, {
1147
- "plugin": plugin["name"],
1148
- "author": "Anthropic",
1149
- "license": "Apache-2.0",
1150
- "repo": "anthropics/claude-plugins-official",
1151
- "branch": "main",
1152
- "plugin_path": plugin_path,
1153
- "category": plugin.get("category", "development"),
1154
- "description": plugin.get("description", ""),
1155
- }))
1156
- # ── 검증된 서드파티 skills ────────────────────────────────────────
1157
- for source in _THIRD_PARTY_SKILL_SOURCES:
1158
- result.extend(await _fetch_plugin_skills(client, source))
1159
-
1160
- _SKILLS_MARKETPLACE_CACHE = result
1161
- _SKILLS_MARKETPLACE_FETCHED_AT = now
1162
- logging.info("Fetched %d skills from marketplace (%d sources)",
1163
- len(result), len(anthropic_plugins) + len(_THIRD_PARTY_SKILL_SOURCES))
1164
- except Exception as e:
1165
- logging.warning("Failed to fetch skills marketplace: %s", e)
1166
- return _SKILLS_MARKETPLACE_CACHE
1167
-
1168
- # ── Plugin Directory ──────────────────────────────────────────────────────────
1169
- _PLUGIN_DIRECTORY_CACHE: List[Dict] = []
1170
- _PLUGIN_DIRECTORY_FETCHED_AT: Optional[datetime] = None
1171
- _PLUGIN_DIRECTORY_TTL = timedelta(hours=1)
1172
- _OPEN_LICENSES = {"Apache-2.0", "MIT", "MIT-0", "CC-BY-4.0"}
1173
- _REPO_LICENSE_CACHE: Dict[str, str] = {}
1174
-
1175
- async def _get_repo_license(client: httpx.AsyncClient, repo: str) -> str:
1176
- if repo in _REPO_LICENSE_CACHE:
1177
- return _REPO_LICENSE_CACHE[repo]
1178
- if repo in _KNOWN_REPO_LICENSES:
1179
- _REPO_LICENSE_CACHE[repo] = _KNOWN_REPO_LICENSES[repo]
1180
- return _KNOWN_REPO_LICENSES[repo]
1181
- try:
1182
- r = await client.get(f"https://api.github.com/repos/{repo}", timeout=5.0)
1183
- lic = (r.json().get("license") or {}).get("spdx_id", "") if r.status_code == 200 else ""
1184
- except Exception:
1185
- lic = ""
1186
- _REPO_LICENSE_CACHE[repo] = lic
1187
- return lic
1188
-
1189
- async def _fetch_plugin_directory() -> List[Dict]:
1190
- global _PLUGIN_DIRECTORY_CACHE, _PLUGIN_DIRECTORY_FETCHED_AT
1191
- now = datetime.now()
1192
- if _PLUGIN_DIRECTORY_FETCHED_AT and (now - _PLUGIN_DIRECTORY_FETCHED_AT) < _PLUGIN_DIRECTORY_TTL:
1193
- return _PLUGIN_DIRECTORY_CACHE
1194
- try:
1195
- result: List[Dict] = []
1196
- async with httpx.AsyncClient(timeout=15.0) as client:
1197
- mp_resp = await client.get(f"{_MARKETPLACE_RAW}/.claude-plugin/marketplace.json")
1198
- mp_resp.raise_for_status()
1199
- plugins = mp_resp.json().get("plugins", [])
1200
-
1201
- for p in plugins:
1202
- author = (p.get("author") or {}).get("name", "")
1203
- src = p.get("source", {})
1204
-
1205
- # Anthropic 같은 레포 플러그인 → Apache-2.0 확인됨
1206
- if isinstance(src, str) and src.startswith("./") and author == "Anthropic":
1207
- plugin_path = src.lstrip("./")
1208
- result.append({
1209
- "name": p["name"],
1210
- "description": p.get("description", ""),
1211
- "category": p.get("category", ""),
1212
- "author": author,
1213
- "license": "Apache-2.0",
1214
- "homepage": p.get("homepage") or f"https://github.com/anthropics/claude-plugins-official/tree/main/{plugin_path}",
1215
- "source_type": "anthropic",
1216
- })
1217
- continue
1218
-
1219
- # 외부 레포 플러그인 → 라이선스 확인
1220
- if not isinstance(src, dict):
1221
- continue
1222
- repo_url = src.get("url", "").replace("https://github.com/", "").replace(".git", "").split("/tree/")[0]
1223
- if not repo_url:
1224
- continue
1225
- license_id = await _get_repo_license(client, repo_url)
1226
- if license_id not in _OPEN_LICENSES:
1227
- continue
1228
- result.append({
1229
- "name": p["name"],
1230
- "description": p.get("description", ""),
1231
- "category": p.get("category", ""),
1232
- "author": author or repo_url.split("/")[0],
1233
- "license": license_id,
1234
- "homepage": p.get("homepage") or f"https://github.com/{repo_url}",
1235
- "source_type": "third-party",
1236
- })
1237
-
1238
- _PLUGIN_DIRECTORY_CACHE = result
1239
- _PLUGIN_DIRECTORY_FETCHED_AT = now
1240
- logging.info("Fetched plugin directory: %d open-source plugins", len(result))
1241
- except Exception as e:
1242
- logging.warning("Failed to fetch plugin directory: %s", e)
1243
- return _PLUGIN_DIRECTORY_CACHE
1244
-
1245
- # ─────────────────────────────────────────────────────────────────────────────
1246
-
1247
- SKILLS_DIR = Path(__file__).resolve().parent / "skills"
1248
-
1249
- async def install_skill(plugin: str, skill: str) -> Dict:
1250
- marketplace = await _fetch_skills_marketplace()
1251
- entry = next((s for s in marketplace if s["plugin"] == plugin and s["skill"] == skill), None)
1252
- if not entry:
1253
- raise HTTPException(status_code=404, detail=f"Skill '{plugin}/{skill}' not found in marketplace")
1254
- skill_dir = SKILLS_DIR / skill
1255
- skill_dir.mkdir(parents=True, exist_ok=True)
1256
- skill_md_path = skill_dir / "SKILL.md"
1257
- async with httpx.AsyncClient(timeout=15.0) as client:
1258
- resp = await client.get(entry["skill_md_url"])
1259
- resp.raise_for_status()
1260
- content = resp.text
1261
- # 출처 표기 (Apache-2.0 / MIT 공통)
1262
- repo_hint = entry.get("homepage", "")
1263
- attribution = f"<!-- Source: {repo_hint}, {entry['license']} -->\n"
1264
- if not content.startswith("<!--"):
1265
- content = attribution + content
1266
- skill_md_path.write_text(content, encoding="utf-8")
1267
- risk_path = skill_dir / "risk.json"
1268
- if not risk_path.exists():
1269
- risk_path.write_text(json.dumps({
1270
- "risk": "read", "destructive": False,
1271
- "shell": False, "network": False,
1272
- "auto_approve": True, "sandbox": "workspace", "rollback": "none"
1273
- }, indent=2), encoding="utf-8")
1274
- return {
1275
- "status": "installed",
1276
- "plugin": plugin,
1277
- "skill": skill,
1278
- "path": str(skill_dir),
1279
- "license": entry["license"],
1280
- "author": entry["author"],
1281
- }
1282
-
1283
- # ─────────────────────────────────────────────────────────────────────────────
1284
509
 
1285
510
  def load_users():
1286
511
  if not os.path.exists(USERS_FILE):
@@ -2199,11 +1424,17 @@ async def lifespan(app: FastAPI):
2199
1424
  print("⏭️ Telegram Bot Bridge disabled for this mode.")
2200
1425
  _spawn(unload_idle_models_loop(), name="unload_idle_models")
2201
1426
  _spawn(autoload_default_model(), name="autoload_default_model")
1427
+ if LOCAL_KG_WATCHER:
1428
+ restored = LOCAL_KG_WATCHER.restore_enabled_sources()
1429
+ if restored.get("restored"):
1430
+ print(f"🕸️ Local knowledge watchers restored: {restored['restored']}")
2202
1431
  except Exception as e:
2203
1432
  print(f"⚠️ Startup sequence failed: {e}")
2204
1433
  try:
2205
1434
  yield
2206
1435
  finally:
1436
+ if LOCAL_KG_WATCHER:
1437
+ LOCAL_KG_WATCHER.stop_all()
2207
1438
  router.unload_all()
2208
1439
  for proc in LOCAL_SERVER_PROCESSES.values():
2209
1440
  try:
@@ -2991,83 +2222,114 @@ ENGINE_INSTALLERS = {
2991
2222
 
2992
2223
  ENGINE_MODEL_CATALOG = {
2993
2224
  "local_mlx": [
2225
+ {"id": "mlx-community/SmolLM-1.7B-Instruct-4bit", "name": "SmolLM 1.7B", "family": "SmolLM", "tag": "local-light", "size": "963MB", "pullable": True},
2226
+ {"id": "mlx-community/gemma-3-1b-it-4bit", "name": "Gemma 3 1B", "family": "Gemma 3", "tag": "local-light", "size": "733MB", "pullable": True},
2227
+ {"id": "mlx-community/Llama-3.2-1B-Instruct-4bit", "name": "Llama 3.2 1B", "family": "Llama 3.x", "tag": "local-light", "size": "1.3GB", "pullable": True},
2228
+ {"id": "mlx-community/gemma-2-2b-it-4bit", "name": "Gemma 2 2B", "family": "Gemma 2", "tag": "local-light", "size": "1.6GB", "pullable": True},
2994
2229
  {"id": "mlx-community/gemma-4-e2b-4bit", "name": "Gemma 4 E2B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "3.6GB", "pullable": True},
2995
2230
  {"id": "mlx-community/gemma-4-e2b-it-4bit", "name": "Gemma 4 E2B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "3.6GB", "pullable": True},
2996
2231
  {"id": "mlx-community/gemma-4-e4b-4bit", "name": "Gemma 4 E4B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
2997
2232
  {"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
2998
- {"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},
2999
- {"id": "Jiunsong/supergemma4-26b-abliterated-multimodal-mlx-4bit", "name": "SuperGemma4 26B Abliterated Multimodal", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
3000
- {"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},
3001
- {"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},
3002
- {"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},
3003
- {"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},
3004
- {"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},
3005
- {"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},
2233
+ {"id": "mlx-community/Qwen3-VL-4B-Instruct-4bit", "name": "Qwen3-VL 4B", "family": "Qwen3-VL", "tag": "local-vlm", "size": "2.7GB", "pullable": True},
2234
+ {"id": "mlx-community/Qwen3-VL-8B-Instruct-4bit", "name": "Qwen3-VL 8B", "family": "Qwen3-VL", "tag": "local-vlm", "size": "4.8GB", "pullable": True},
2235
+ {"id": "mlx-community/Qwen2.5-VL-7B-Instruct-4bit", "name": "Qwen2.5-VL 7B", "family": "Qwen2.5-VL", "tag": "local-vlm", "size": "4.4GB", "pullable": True},
2236
+ {"id": "mlx-community/gemma-3-4b-it-4bit", "name": "Gemma 3 4B", "family": "Gemma 3", "tag": "local-vlm", "size": "3.3GB", "pullable": True},
3006
2237
  {"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},
3007
2238
  {"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},
2239
+ {"id": "mlx-community/gemma-2-9b-it-4bit", "name": "Gemma 2 9B", "family": "Gemma 2", "tag": "local-general", "size": "5.4GB", "pullable": True},
2240
+ {"id": "mlx-community/gemma-3-12b-it-4bit", "name": "Gemma 3 12B", "family": "Gemma 3", "tag": "local-vlm", "size": "8.0GB", "pullable": True},
2241
+ {"id": "mlx-community/Phi-3.5-mini-instruct-4bit", "name": "Phi 3.5 Mini", "family": "Phi", "tag": "local-coding", "size": "2.2GB", "pullable": True},
2242
+ {"id": "mlx-community/Phi-4-mini-instruct-4bit", "name": "Phi 4 Mini", "family": "Phi", "tag": "local-coding", "size": "2.2GB", "pullable": True},
2243
+ {"id": "mlx-community/phi-4-4bit", "name": "Phi 4", "family": "Phi", "tag": "local-coding", "size": "8.3GB", "pullable": True},
2244
+ {"id": "mlx-community/Mistral-7B-Instruct-v0.3-4bit", "name": "Mistral 7B Instruct v0.3", "family": "Mistral", "tag": "local-general", "size": "4.1GB", "pullable": True},
2245
+ {"id": "mlx-community/Ministral-8B-Instruct-2410-4bit", "name": "Ministral 8B Instruct", "family": "Mistral", "tag": "local-general", "size": "4.5GB", "pullable": True},
2246
+ {"id": "mlx-community/Mistral-Small-24B-Instruct-2501-4bit", "name": "Mistral Small 24B", "family": "Mistral", "tag": "local-large", "size": "13.3GB", "pullable": True},
2247
+ {"id": "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit", "name": "Qwen2.5 Coder 32B", "family": "Qwen2.5", "tag": "local-coding", "size": "18.5GB", "pullable": True},
2248
+ {"id": "mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "name": "Qwen3-VL 30B A3B", "family": "Qwen3-VL", "tag": "local-vlm", "size": "18GB", "pullable": True},
2249
+ {"id": "mlx-community/gemma-3-27b-it-4bit", "name": "Gemma 3 27B", "family": "Gemma 3", "tag": "local-vlm", "size": "17GB", "pullable": True},
2250
+ {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "15.6GB", "pullable": True},
3008
2251
  {"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},
3009
2252
  {"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},
3010
- {"id": "mlx-community/Phi-3.5-mini-instruct-4bit", "name": "Phi 3.5 Mini", "family": "Phi", "tag": "local-light", "size": "2.2GB", "pullable": True},
3011
- {"id": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit", "name": "DeepSeek R1 Distill 7B", "family": "DeepSeek", "tag": "reasoning", "size": "4.3GB", "pullable": True},
3012
2253
  ],
3013
2254
  "ollama": [
2255
+ {"id": "ollama:qwen3-vl:4b", "name": "Qwen3-VL 4B via Ollama", "family": "Qwen3-VL", "tag": "local-vlm", "size": "pull required", "pullable": True},
2256
+ {"id": "ollama:qwen3-vl:8b", "name": "Qwen3-VL 8B via Ollama", "family": "Qwen3-VL", "tag": "local-vlm", "size": "pull required", "pullable": True},
2257
+ {"id": "ollama:qwen3-vl:30b", "name": "Qwen3-VL 30B via Ollama", "family": "Qwen3-VL", "tag": "local-vlm", "size": "pull required", "pullable": True},
2258
+ {"id": "ollama:qwen3:8b", "name": "Qwen3 8B via Ollama", "family": "Qwen", "tag": "local-server", "size": "pull required", "pullable": True},
2259
+ {"id": "ollama:qwen2.5-coder:14b", "name": "Qwen2.5 Coder 14B via Ollama", "family": "Qwen", "tag": "local-coding", "size": "pull required", "pullable": True},
2260
+ {"id": "ollama:gemma3:1b", "name": "Gemma 3 1B via Ollama", "family": "Gemma", "tag": "local-light", "size": "pull required", "pullable": True},
3014
2261
  {"id": "ollama:gemma3:4b", "name": "Gemma 3 4B via Ollama", "family": "Gemma", "tag": "local-server", "size": "pull required", "pullable": True},
3015
2262
  {"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},
3016
2263
  {"id": "ollama:gemma3:12b", "name": "Gemma 3 12B via Ollama", "family": "Gemma", "tag": "local-server", "size": "pull required", "pullable": True},
3017
2264
  {"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},
3018
- {"id": "ollama:qwen2.5:3b", "name": "Qwen 2.5 3B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
3019
- {"id": "ollama:qwen2.5:7b", "name": "Qwen 2.5 7B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
3020
- {"id": "ollama:qwen2.5:14b", "name": "Qwen 2.5 14B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
3021
- {"id": "ollama:qwen2.5:32b", "name": "Qwen 2.5 32B via Ollama", "family": "Qwen 2.5", "tag": "local-server", "size": "pull required", "pullable": True},
3022
- {"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},
3023
- {"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},
2265
+ {"id": "ollama:gemma3:27b", "name": "Gemma 3 27B via Ollama", "family": "Gemma", "tag": "local-large", "size": "pull required", "pullable": True},
2266
+ {"id": "ollama:llama3.2:1b", "name": "Llama 3.2 1B via Ollama", "family": "Llama 3.x", "tag": "local-light", "size": "pull required", "pullable": True},
3024
2267
  {"id": "ollama:llama3.2:3b", "name": "Llama 3.2 3B via Ollama", "family": "Llama 3.x", "tag": "local-server", "size": "pull required", "pullable": True},
3025
2268
  {"id": "ollama:llama3.1:8b", "name": "Llama 3.1 8B via Ollama", "family": "Llama 3.1", "tag": "local-server", "size": "pull required", "pullable": True},
3026
2269
  {"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},
3027
2270
  {"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},
3028
2271
  {"id": "ollama:llama3.1:70b", "name": "Llama 3.1 70B via Ollama", "family": "Llama 3.1", "tag": "local-server", "size": "pull required", "pullable": True},
2272
+ {"id": "ollama:llama3.3:70b", "name": "Llama 3.3 70B via Ollama", "family": "Llama 3.x", "tag": "local-large", "size": "pull required", "pullable": True},
2273
+ {"id": "ollama:mistral:7b", "name": "Mistral 7B via Ollama", "family": "Mistral", "tag": "local-server", "size": "pull required", "pullable": True},
2274
+ {"id": "ollama:mixtral:8x7b", "name": "Mixtral 8x7B via Ollama", "family": "Mistral", "tag": "local-large", "size": "pull required", "pullable": True},
2275
+ {"id": "ollama:phi4-mini", "name": "Phi 4 Mini via Ollama", "family": "Phi", "tag": "local-coding", "size": "pull required", "pullable": True},
2276
+ {"id": "ollama:phi4", "name": "Phi 4 via Ollama", "family": "Phi", "tag": "local-coding", "size": "pull required", "pullable": True},
2277
+ {"id": "ollama:smollm2:1.7b", "name": "SmolLM2 1.7B via Ollama", "family": "SmolLM", "tag": "local-light", "size": "pull required", "pullable": True},
3029
2278
  ],
3030
2279
  "vllm": [
3031
- {"id": "vllm:Qwen/Qwen2.5-0.5B-Instruct-AWQ", "name": "Qwen 2.5 0.5B AWQ via vLLM", "family": "Qwen 2.5", "tag": "local-light", "size": "0.5B", "pullable": True},
2280
+ {"id": "vllm:Qwen/Qwen3-VL-4B-Instruct", "name": "Qwen3-VL 4B via vLLM", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
2281
+ {"id": "vllm:Qwen/Qwen3-VL-8B-Instruct", "name": "Qwen3-VL 8B via vLLM", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
2282
+ {"id": "vllm:Qwen/Qwen3-VL-30B-A3B-Instruct", "name": "Qwen3-VL 30B A3B via vLLM", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
2283
+ {"id": "vllm:Qwen/Qwen2.5-VL-7B-Instruct", "name": "Qwen2.5-VL 7B via vLLM", "family": "Qwen2.5-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
3032
2284
  {"id": "vllm:google/gemma-2-2b", "name": "Gemma 2 2B Base via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
3033
2285
  {"id": "vllm:google/gemma-2-2b-it", "name": "Gemma 2 2B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
3034
2286
  {"id": "vllm:google/gemma-2-9b", "name": "Gemma 2 9B Base via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
3035
2287
  {"id": "vllm:google/gemma-2-9b-it", "name": "Gemma 2 9B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
3036
- {"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},
3037
- {"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},
3038
- {"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},
3039
- {"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},
3040
- {"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},
3041
- {"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},
2288
+ {"id": "vllm:google/gemma-3-4b-it", "name": "Gemma 3 4B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
2289
+ {"id": "vllm:google/gemma-3-12b-it", "name": "Gemma 3 12B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
2290
+ {"id": "vllm:microsoft/Phi-3.5-mini-instruct", "name": "Phi 3.5 Mini via vLLM", "family": "Phi", "tag": "local-coding", "size": "server model", "pullable": True},
2291
+ {"id": "vllm:microsoft/Phi-4-mini-instruct", "name": "Phi 4 Mini via vLLM", "family": "Phi", "tag": "local-coding", "size": "server model", "pullable": True},
2292
+ {"id": "vllm:microsoft/phi-4", "name": "Phi 4 via vLLM", "family": "Phi", "tag": "local-coding", "size": "server model", "pullable": True},
2293
+ {"id": "vllm:mistralai/Mistral-7B-Instruct-v0.3", "name": "Mistral 7B via vLLM", "family": "Mistral", "tag": "local-server", "size": "server model", "pullable": True},
2294
+ {"id": "vllm:mistralai/Ministral-8B-Instruct-2410", "name": "Ministral 8B via vLLM", "family": "Mistral", "tag": "local-server", "size": "server model", "pullable": True},
2295
+ {"id": "vllm:mistralai/Mistral-Small-24B-Instruct-2501", "name": "Mistral Small 24B via vLLM", "family": "Mistral", "tag": "local-large", "size": "server model", "pullable": True},
3042
2296
  {"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},
3043
2297
  {"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},
2298
+ {"id": "vllm:meta-llama/Llama-3.3-70B-Instruct", "name": "Llama 3.3 70B via vLLM", "family": "Llama 3.x", "tag": "local-large", "size": "server model", "pullable": True},
3044
2299
  {"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},
3045
2300
  ],
3046
2301
  "lmstudio": [
3047
- {"id": "lmstudio:https://huggingface.co/lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF", "name": "Qwen 2.5 0.5B GGUF via LM Studio", "family": "Qwen 2.5", "tag": "local-light", "size": "0.5B", "pullable": True},
2302
+ {"id": "lmstudio:Qwen/Qwen3-VL-4B-Instruct", "name": "Qwen3-VL 4B via LM Studio", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
2303
+ {"id": "lmstudio:Qwen/Qwen3-VL-8B-Instruct", "name": "Qwen3-VL 8B via LM Studio", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
2304
+ {"id": "lmstudio:Qwen/Qwen3-VL-30B-A3B-Instruct", "name": "Qwen3-VL 30B A3B via LM Studio", "family": "Qwen3-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
2305
+ {"id": "lmstudio:Qwen/Qwen2.5-VL-7B-Instruct", "name": "Qwen2.5-VL 7B via LM Studio", "family": "Qwen2.5-VL", "tag": "local-vlm", "size": "server model", "pullable": True},
3048
2306
  {"id": "lmstudio:google/gemma-2-2b-it", "name": "Gemma 2 2B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
3049
2307
  {"id": "lmstudio:google/gemma-2-9b-it", "name": "Gemma 2 9B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
3050
- {"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},
3051
- {"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},
3052
- {"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},
3053
- {"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},
3054
- {"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},
3055
- {"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},
2308
+ {"id": "lmstudio:google/gemma-3-4b-it", "name": "Gemma 3 4B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
2309
+ {"id": "lmstudio:google/gemma-3-12b-it", "name": "Gemma 3 12B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
2310
+ {"id": "lmstudio:microsoft/Phi-3.5-mini-instruct", "name": "Phi 3.5 Mini via LM Studio", "family": "Phi", "tag": "local-coding", "size": "server model", "pullable": True},
2311
+ {"id": "lmstudio:microsoft/Phi-4-mini-instruct", "name": "Phi 4 Mini via LM Studio", "family": "Phi", "tag": "local-coding", "size": "server model", "pullable": True},
2312
+ {"id": "lmstudio:microsoft/phi-4", "name": "Phi 4 via LM Studio", "family": "Phi", "tag": "local-coding", "size": "server model", "pullable": True},
2313
+ {"id": "lmstudio:mistralai/Mistral-7B-Instruct-v0.3", "name": "Mistral 7B via LM Studio", "family": "Mistral", "tag": "local-server", "size": "server model", "pullable": True},
2314
+ {"id": "lmstudio:mistralai/Ministral-8B-Instruct-2410", "name": "Ministral 8B via LM Studio", "family": "Mistral", "tag": "local-server", "size": "server model", "pullable": True},
2315
+ {"id": "lmstudio:mistralai/Mistral-Small-24B-Instruct-2501", "name": "Mistral Small 24B via LM Studio", "family": "Mistral", "tag": "local-large", "size": "server model", "pullable": True},
3056
2316
  {"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},
3057
2317
  {"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},
2318
+ {"id": "lmstudio:meta-llama/Llama-3.3-70B-Instruct", "name": "Llama 3.3 70B via LM Studio", "family": "Llama 3.x", "tag": "local-large", "size": "server model", "pullable": True},
3058
2319
  {"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},
3059
2320
  ],
3060
2321
  "llamacpp": [
3061
- {"id": "llamacpp:lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF", "name": "Qwen 2.5 0.5B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "gguf-q4", "size": "0.5B", "pullable": True},
2322
+ {"id": "llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF", "name": "Qwen3-VL 4B GGUF via llama.cpp", "family": "Qwen3-VL", "tag": "gguf-vlm", "size": "gguf", "pullable": True},
2323
+ {"id": "llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF", "name": "Qwen3-VL 8B GGUF via llama.cpp", "family": "Qwen3-VL", "tag": "gguf-vlm", "size": "gguf", "pullable": True},
3062
2324
  {"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},
3063
2325
  {"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},
3064
- {"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},
3065
- {"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},
3066
- {"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},
3067
- {"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},
3068
- {"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},
2326
+ {"id": "llamacpp:unsloth/gemma-3-4b-it-GGUF", "name": "Gemma 3 4B GGUF via llama.cpp", "family": "Gemma", "tag": "gguf-q4", "size": "gguf", "pullable": True},
2327
+ {"id": "llamacpp:bartowski/Mistral-7B-Instruct-v0.3-GGUF", "name": "Mistral 7B GGUF via llama.cpp", "family": "Mistral", "tag": "gguf-q4", "size": "gguf", "pullable": True},
2328
+ {"id": "llamacpp:bartowski/Phi-3.5-mini-instruct-GGUF", "name": "Phi 3.5 Mini GGUF via llama.cpp", "family": "Phi", "tag": "gguf-q4", "size": "gguf", "pullable": True},
2329
+ {"id": "llamacpp:bartowski/phi-4-GGUF", "name": "Phi 4 GGUF via llama.cpp", "family": "Phi", "tag": "gguf-q4", "size": "gguf", "pullable": True},
3069
2330
  {"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},
3070
2331
  {"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},
2332
+ {"id": "llamacpp:bartowski/Llama-3.3-70B-Instruct-GGUF", "name": "Llama 3.3 70B GGUF via llama.cpp", "family": "Llama 3.x", "tag": "local-large", "size": "gguf", "pullable": True},
3071
2333
  {"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},
3072
2334
  ],
3073
2335
  }
@@ -3093,8 +2355,40 @@ VLLM_METAL_BIN = VLLM_METAL_ENV / "bin" / "vllm"
3093
2355
  VLLM_METAL_PYTHON = VLLM_METAL_ENV / "bin" / "python"
3094
2356
  LMSTUDIO_BUNDLED_CLI = Path("/Applications/LM Studio.app/Contents/Resources/app/.webpack/lms")
3095
2357
 
2358
+ def windows_binary_candidates(binary: str) -> List[Path]:
2359
+ local_appdata = os.environ.get("LOCALAPPDATA", "")
2360
+ program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
2361
+ program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
2362
+ candidates = {
2363
+ "ollama": [
2364
+ Path(local_appdata) / "Programs" / "Ollama" / "ollama.exe" if local_appdata else None,
2365
+ Path(program_files) / "Ollama" / "ollama.exe",
2366
+ ],
2367
+ "lms": [
2368
+ Path(local_appdata) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe" if local_appdata else None,
2369
+ Path(program_files) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe",
2370
+ ],
2371
+ "nvidia-smi": [
2372
+ Path(program_files) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
2373
+ Path(program_files_x86) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
2374
+ ],
2375
+ }
2376
+ return [item for item in candidates.get(binary, []) if item is not None]
2377
+
2378
+
2379
+ def local_binary(binary: str) -> Optional[str]:
2380
+ found = shutil.which(binary)
2381
+ if found:
2382
+ return found
2383
+ if platform.system() == "Windows":
2384
+ for candidate in windows_binary_candidates(binary):
2385
+ if candidate.exists():
2386
+ return str(candidate)
2387
+ return None
2388
+
2389
+
3096
2390
  def find_lmstudio_cli() -> Optional[str]:
3097
- cli = shutil.which("lms")
2391
+ cli = local_binary("lms")
3098
2392
  if cli:
3099
2393
  return cli
3100
2394
  if LMSTUDIO_BUNDLED_CLI.exists():
@@ -3318,6 +2612,8 @@ def engine_support_status(engine: str) -> Dict[str, object]:
3318
2612
  if engine != "vllm":
3319
2613
  return {"supported": True, "reason": None}
3320
2614
  is_apple_silicon = sys.platform == "darwin" and platform.machine() == "arm64"
2615
+ if sys.platform.startswith("win"):
2616
+ return {"supported": False, "reason": "vLLM은 Windows native 자동 설치보다 WSL2/Linux 환경을 권장합니다."}
3321
2617
  if sys.platform == "darwin" and not is_apple_silicon:
3322
2618
  return {"supported": False, "reason": "vLLM Metal 자동 설치는 Apple Silicon macOS에서만 지원됩니다."}
3323
2619
  if sys.version_info >= (3, 13) and is_apple_silicon:
@@ -3574,6 +2870,9 @@ def download_hf_model(
3574
2870
 
3575
2871
 
3576
2872
  def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
2873
+ ollama = local_binary("ollama")
2874
+ if not ollama:
2875
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
3577
2876
  started_at = time.time()
3578
2877
  if progress_emit:
3579
2878
  progress_emit(model_download_progress_payload(
@@ -3584,7 +2883,7 @@ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict
3584
2883
  indeterminate=True,
3585
2884
  ))
3586
2885
  process = subprocess.Popen(
3587
- ["ollama", "pull", model_name],
2886
+ [ollama, "pull", model_name],
3588
2887
  stdout=subprocess.PIPE,
3589
2888
  stderr=subprocess.STDOUT,
3590
2889
  text=True,
@@ -3643,10 +2942,11 @@ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict
3643
2942
 
3644
2943
 
3645
2944
  def get_ollama_pulled_models() -> set:
3646
- if not shutil.which("ollama"):
2945
+ ollama = local_binary("ollama")
2946
+ if not ollama:
3647
2947
  return set()
3648
2948
  try:
3649
- result = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=5, check=False)
2949
+ result = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=5, check=False)
3650
2950
  pulled = set()
3651
2951
  for line in result.stdout.splitlines()[1:]:
3652
2952
  parts = line.split()
@@ -3701,16 +3001,17 @@ def get_openai_compatible_server_models(provider: str) -> List[str]:
3701
3001
 
3702
3002
 
3703
3003
  def ensure_ollama_server() -> None:
3704
- if not shutil.which("ollama"):
3004
+ ollama = local_binary("ollama")
3005
+ if not ollama:
3705
3006
  raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
3706
3007
  try:
3707
- probe = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=3, check=False)
3008
+ probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
3708
3009
  if probe.returncode == 0:
3709
3010
  return
3710
3011
  except Exception:
3711
3012
  pass
3712
3013
  subprocess.Popen(
3713
- ["ollama", "serve"],
3014
+ [ollama, "serve"],
3714
3015
  stdout=subprocess.DEVNULL,
3715
3016
  stderr=subprocess.DEVNULL,
3716
3017
  start_new_session=True,
@@ -3718,7 +3019,7 @@ def ensure_ollama_server() -> None:
3718
3019
  deadline = time.time() + 20
3719
3020
  while time.time() < deadline:
3720
3021
  try:
3721
- probe = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=3, check=False)
3022
+ probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
3722
3023
  if probe.returncode == 0:
3723
3024
  return
3724
3025
  except Exception:
@@ -3829,7 +3130,7 @@ def engine_installed(engine: str) -> bool:
3829
3130
  if engine == "local_mlx":
3830
3131
  return bool(importlib.util.find_spec("mlx") and importlib.util.find_spec("mlx_lm"))
3831
3132
  if engine == "ollama":
3832
- return shutil.which("ollama") is not None
3133
+ return local_binary("ollama") is not None
3833
3134
  if engine == "vllm":
3834
3135
  return vllm_metal_python() is not None or vllm_executable() is not None or importlib.util.find_spec("vllm") is not None
3835
3136
  if engine == "lmstudio":
@@ -4068,11 +3369,12 @@ def install_engine(engine: str) -> Dict:
4068
3369
  "stderr": completed.stderr[-12000:],
4069
3370
  "installed": engine_installed(engine),
4070
3371
  }
4071
- if engine == "ollama" and completed.returncode == 0 and shutil.which("ollama"):
3372
+ ollama = local_binary("ollama")
3373
+ if engine == "ollama" and completed.returncode == 0 and ollama:
4072
3374
  # Skip if already running to avoid orphan daemons.
4073
3375
  already_up = False
4074
3376
  try:
4075
- probe = subprocess.run(["ollama", "list"], capture_output=True, timeout=2, check=False)
3377
+ probe = subprocess.run([ollama, "list"], capture_output=True, timeout=2, check=False)
4076
3378
  already_up = probe.returncode == 0
4077
3379
  except Exception:
4078
3380
  already_up = False
@@ -4082,7 +3384,7 @@ def install_engine(engine: str) -> Dict:
4082
3384
  try:
4083
3385
  # Detach so the daemon survives this request but doesn't become our zombie.
4084
3386
  subprocess.Popen(
4085
- ["ollama", "serve"],
3387
+ [ollama, "serve"],
4086
3388
  stdout=subprocess.DEVNULL,
4087
3389
  stderr=subprocess.DEVNULL,
4088
3390
  start_new_session=True,
@@ -4159,9 +3461,12 @@ async def prepare_and_load_model(
4159
3461
  download_result = download_hf_model(parsed_model, "local_mlx")
4160
3462
  elif parsed_provider == "ollama":
4161
3463
  ensure_ollama_server()
3464
+ ollama = local_binary("ollama")
3465
+ if not ollama:
3466
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
4162
3467
  if parsed_model not in get_ollama_pulled_models():
4163
3468
  completed = subprocess.run(
4164
- ["ollama", "pull", parsed_model],
3469
+ [ollama, "pull", parsed_model],
4165
3470
  capture_output=True,
4166
3471
  text=True,
4167
3472
  timeout=900,
@@ -4545,9 +3850,12 @@ async def pull_ollama_model(req: PullModelRequest, request: Request):
4545
3850
 
4546
3851
  if provider == "ollama":
4547
3852
  ensure_ollama_server()
3853
+ ollama = local_binary("ollama")
3854
+ if not ollama:
3855
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
4548
3856
  try:
4549
3857
  completed = subprocess.run(
4550
- ["ollama", "pull", model_name],
3858
+ [ollama, "pull", model_name],
4551
3859
  capture_output=True, text=True, timeout=900, check=False,
4552
3860
  )
4553
3861
  except subprocess.TimeoutExpired:
@@ -4644,21 +3952,23 @@ async def set_api_key(req: SetApiKeyRequest, request: Request):
4644
3952
  async def list_models():
4645
3953
  """HuggingFace 추천 모델 목록 및 로드 상태 반환"""
4646
3954
  recommended = [
4647
- # Qwen Series
4648
- {"id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", "name": "Qwen 2.5 Coder 7B", "tag": "coding", "size": "4.3GB"},
4649
- {"id": "mlx-community/Qwen2.5-7B-Instruct-4bit", "name": "Qwen 2.5 7B", "tag": "general", "size": "4.3GB"},
4650
-
4651
- # Llama Series
4652
- {"id": "mlx-community/Llama-3.2-3B-Instruct-4bit", "name": "Llama 3.2 3B", "tag": "light", "size": "2.0GB"},
4653
- {"id": "mlx-community/Llama-3.1-8B-Instruct-4bit", "name": "Llama 3.1 8B", "tag": "general", "size": "4.7GB"},
4654
-
4655
- # Gemma Series
4656
- {"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B (4-bit)", "tag": "next-gen", "size": "5.2GB"},
4657
- {"id": "mlx-community/gemma-2-9b-it-4bit", "name": "Gemma 2 9B", "tag": "balanced","size": "5.4GB"},
4658
- {"id": "mlx-community/gemma-2-2b-it-4bit", "name": "Gemma 2 2B", "tag": "ultra-light", "size": "1.6GB"},
4659
-
4660
- # Reasoning
4661
- {"id": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit","name": "DeepSeek R1 (7B)", "tag": "reasoning","size": "4.3GB"},
3955
+ {"id": "mlx-community/Qwen3-VL-4B-Instruct-4bit", "name": "Qwen3-VL 4B", "tag": "multimodal", "size": "2.7GB"},
3956
+ {"id": "mlx-community/Qwen3-VL-8B-Instruct-4bit", "name": "Qwen3-VL 8B", "tag": "multimodal", "size": "4.8GB"},
3957
+ {"id": "mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "name": "Qwen3-VL 30B A3B","tag": "multimodal", "size": "18GB"},
3958
+ {"id": "mlx-community/SmolLM-1.7B-Instruct-4bit", "name": "SmolLM 1.7B", "tag": "ultra-light", "size": "963MB"},
3959
+ {"id": "mlx-community/gemma-3-1b-it-4bit", "name": "Gemma 3 1B", "tag": "ultra-light", "size": "733MB"},
3960
+ {"id": "mlx-community/Llama-3.2-1B-Instruct-4bit", "name": "Llama 3.2 1B", "tag": "light", "size": "1.3GB"},
3961
+ {"id": "mlx-community/Llama-3.2-3B-Instruct-4bit", "name": "Llama 3.2 3B", "tag": "light", "size": "2.0GB"},
3962
+ {"id": "mlx-community/Phi-4-mini-instruct-4bit", "name": "Phi 4 Mini", "tag": "coding", "size": "2.2GB"},
3963
+ {"id": "mlx-community/Qwen2.5-VL-7B-Instruct-4bit", "name": "Qwen2.5-VL 7B", "tag": "multimodal", "size": "4.4GB"},
3964
+ {"id": "mlx-community/Mistral-7B-Instruct-v0.3-4bit", "name": "Mistral 7B v0.3", "tag": "general", "size": "4.1GB"},
3965
+ {"id": "mlx-community/Llama-3.1-8B-Instruct-4bit", "name": "Llama 3.1 8B", "tag": "general", "size": "4.7GB"},
3966
+ {"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B", "tag": "multimodal", "size": "5.2GB"},
3967
+ {"id": "mlx-community/gemma-3-12b-it-4bit", "name": "Gemma 3 12B", "tag": "balanced", "size": "8.0GB"},
3968
+ {"id": "mlx-community/phi-4-4bit", "name": "Phi 4", "tag": "coding", "size": "8.3GB"},
3969
+ {"id": "mlx-community/Mistral-Small-24B-Instruct-2501-4bit", "name": "Mistral Small 24B", "tag": "large", "size": "13.3GB"},
3970
+ {"id": "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit", "name": "Qwen2.5 Coder 32B","tag": "coding", "size": "18.5GB"},
3971
+ {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B", "tag": "multimodal", "size": "15.6GB"},
4662
3972
  ]
4663
3973
  return {
4664
3974
  "recommended": recommended,
@@ -4971,97 +4281,6 @@ async def search_history(q: str, request: Request):
4971
4281
  grouped[cid]["messages"].append(item)
4972
4282
  return {"results": list(grouped.values())[-30:], "query": q}
4973
4283
 
4974
-
4975
- @app.get("/graph")
4976
- async def knowledge_graph_page(request: Request):
4977
- """Serve the interactive knowledge graph canvas UI."""
4978
- _require_graph()
4979
- require_user(request)
4980
- return FileResponse(STATIC_DIR / "graph.html")
4981
-
4982
-
4983
- @app.get("/knowledge-graph")
4984
- async def knowledge_graph_legacy_page(request: Request):
4985
- """Backward-compatible route for the graph page."""
4986
- _require_graph()
4987
- require_user(request)
4988
- return FileResponse(STATIC_DIR / "graph.html")
4989
-
4990
-
4991
- @app.get("/knowledge-graph/stats")
4992
- async def knowledge_graph_stats(request: Request):
4993
- _require_graph()
4994
- require_user(request)
4995
- return KNOWLEDGE_GRAPH.stats()
4996
-
4997
- @app.get("/knowledge-graph/schema")
4998
- async def knowledge_graph_schema(request: Request):
4999
- _require_graph()
5000
- require_user(request)
5001
- stats = KNOWLEDGE_GRAPH.stats()
5002
- return {
5003
- "legacy_schema_version": stats.get("schema_version"),
5004
- "v2_schema_available": stats.get("v2_schema_available"),
5005
- "v2": stats.get("v2"),
5006
- }
5007
-
5008
-
5009
- @app.get("/knowledge-graph/graph")
5010
- async def knowledge_graph_data(request: Request, limit: int = 300):
5011
- _require_graph()
5012
- require_user(request)
5013
- return KNOWLEDGE_GRAPH.graph(limit)
5014
-
5015
-
5016
- @app.get("/knowledge-graph/search")
5017
- async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
5018
- _require_graph()
5019
- require_user(request)
5020
- if not q or not q.strip():
5021
- return {"query": q, "matches": []}
5022
- return KNOWLEDGE_GRAPH.search(q, limit)
5023
-
5024
-
5025
- @app.get("/knowledge-graph/context")
5026
- async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
5027
- _require_graph()
5028
- require_user(request)
5029
- return {"query": q, "context": KNOWLEDGE_GRAPH.context_for_query(q, limit)}
5030
-
5031
-
5032
- @app.get("/knowledge-graph/neighbors/{node_id:path}")
5033
- async def knowledge_graph_neighbors(node_id: str, request: Request):
5034
- _require_graph()
5035
- require_user(request)
5036
- if not node_id:
5037
- raise HTTPException(status_code=400, detail="node_id required")
5038
- return KNOWLEDGE_GRAPH.neighbors(node_id)
5039
-
5040
-
5041
- @app.post("/knowledge-graph/ingest")
5042
- async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
5043
- _require_graph()
5044
- current_user = require_user(request)
5045
- event_type = (req.type or "").strip().lower()
5046
- if event_type not in {"message", "ai_response", "note"}:
5047
- raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
5048
- role = req.role or ("assistant" if event_type == "ai_response" else "user")
5049
- return KNOWLEDGE_GRAPH.ingest_message(
5050
- role,
5051
- req.content,
5052
- user_email=req.user_email or current_user,
5053
- user_nickname=req.user_nickname,
5054
- source=req.source or "mcp",
5055
- conversation_id=req.conversation_id,
5056
- raw={
5057
- "type": req.type,
5058
- "title": req.title,
5059
- "content": req.content,
5060
- "metadata": req.metadata or {},
5061
- },
5062
- )
5063
-
5064
-
5065
4284
  async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
5066
4285
  full_response = ""
5067
4286
  async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
@@ -6202,24 +5421,26 @@ async def tools_read_document(req: ToolPathRequest, request: Request):
6202
5421
 
6203
5422
  @app.get("/tools/pdf_pages")
6204
5423
  async def tools_pdf_pages(path: str, request: Request, approval_token: Optional[str] = None):
6205
- """Render PDF pages as base64 PNG images using PyMuPDF."""
5424
+ """Render PDF pages as base64 PNG images using pypdfium2 (Apache-2.0)."""
6206
5425
  current_user = require_user(request)
6207
5426
  _require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
6208
5427
  target = Path(path).expanduser().resolve()
6209
5428
  if not target.exists() or not target.is_file():
6210
5429
  raise HTTPException(status_code=404, detail="File not found")
6211
- import fitz # PyMuPDF
5430
+ import io
5431
+ import pypdfium2 as pdfium
6212
5432
  doc = None
6213
5433
  try:
6214
- doc = fitz.open(str(target))
5434
+ doc = pdfium.PdfDocument(str(target))
6215
5435
  total = len(doc)
6216
5436
  pages = []
6217
- for i, page in enumerate(doc):
6218
- if i >= 20: # 최대 20페이지
6219
- break
6220
- mat = fitz.Matrix(1.5, 1.5)
6221
- pix = page.get_pixmap(matrix=mat)
6222
- b64 = base64.b64encode(pix.tobytes("png")).decode()
5437
+ for i in range(min(total, 20)): # 최대 20페이지
5438
+ page = doc[i]
5439
+ bitmap = page.render(scale=1.5)
5440
+ pil_image = bitmap.to_pil()
5441
+ buf = io.BytesIO()
5442
+ pil_image.save(buf, format="PNG")
5443
+ b64 = base64.b64encode(buf.getvalue()).decode()
6223
5444
  pages.append({"page": i + 1, "b64": b64})
6224
5445
  return {"total": total, "pages": pages}
6225
5446
  except Exception as e:
@@ -6229,7 +5450,7 @@ async def tools_pdf_pages(path: str, request: Request, approval_token: Optional[
6229
5450
  try:
6230
5451
  doc.close()
6231
5452
  except Exception as e:
6232
- logging.warning("fitz doc close failed: %s", e)
5453
+ logging.warning("pypdfium2 doc close failed: %s", e)
6233
5454
 
6234
5455
 
6235
5456
  @app.get("/tools/download")
@@ -6685,6 +5906,24 @@ async def local_write_endpoint(req: LocalWriteRequest, request: Request):
6685
5906
  return _tool_response(local_write, req.path, req.content)
6686
5907
 
6687
5908
 
5909
+ app.include_router(create_knowledge_graph_router(
5910
+ get_graph=lambda: KNOWLEDGE_GRAPH,
5911
+ require_graph=_require_graph,
5912
+ require_user=require_user,
5913
+ static_dir=STATIC_DIR,
5914
+ ))
5915
+
5916
+ app.include_router(create_local_knowledge_router(
5917
+ get_graph=lambda: KNOWLEDGE_GRAPH,
5918
+ require_graph=_require_graph,
5919
+ require_user=require_user,
5920
+ require_local_user=_require_local_user,
5921
+ local_permission_response=_local_permission_response,
5922
+ require_local_approval=_require_local_approval,
5923
+ watcher=LOCAL_KG_WATCHER,
5924
+ ))
5925
+
5926
+
6688
5927
  @app.get("/tools/chrome_status")
6689
5928
  async def tools_chrome_status(request: Request):
6690
5929
  require_user(request)
@@ -7167,10 +6406,9 @@ async def mcp_connector(mcp_id: str, request: Request):
7167
6406
  @app.post("/mcp/registry/refresh")
7168
6407
  async def mcp_registry_refresh(request: Request):
7169
6408
  require_user(request)
7170
- global _REMOTE_REGISTRY_FETCHED_AT
7171
- _REMOTE_REGISTRY_FETCHED_AT = None
6409
+ mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
7172
6410
  registry = await _get_combined_registry()
7173
- return {"status": "ok", "total": len(registry), "remote": len(_REMOTE_REGISTRY_CACHE)}
6411
+ return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
7174
6412
 
7175
6413
 
7176
6414
  @app.get("/mcp/claude-code-servers")
@@ -7331,8 +6569,7 @@ async def skills_list(request: Request):
7331
6569
  async def skills_marketplace_refresh(request: Request):
7332
6570
  """Skills 마켓플레이스 캐시 강제 갱신"""
7333
6571
  require_user(request)
7334
- global _SKILLS_MARKETPLACE_FETCHED_AT
7335
- _SKILLS_MARKETPLACE_FETCHED_AT = None
6572
+ mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
7336
6573
  skills = await _fetch_skills_marketplace()
7337
6574
  by_author = {}
7338
6575
  for s in skills:
@@ -7375,8 +6612,7 @@ async def plugins_directory(
7375
6612
  async def plugins_directory_refresh(request: Request):
7376
6613
  """플러그인 디렉터리 캐시 강제 갱신"""
7377
6614
  require_user(request)
7378
- global _PLUGIN_DIRECTORY_FETCHED_AT
7379
- _PLUGIN_DIRECTORY_FETCHED_AT = None
6615
+ mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
7380
6616
  plugins = await _fetch_plugin_directory()
7381
6617
  by_license = {}
7382
6618
  for p in plugins:
@@ -7477,24 +6713,32 @@ async def setup_scan(request: Request):
7477
6713
  primary_model = primary_setup_model(recs)
7478
6714
  if primary_model:
7479
6715
  model_id = primary_model.get("model_id") or (primary_model.get("action") or {}).get("model_id")
6716
+ model_provider, provider_model = parse_model_ref(str(model_id))
6717
+ primary_runtime = "mlx" if model_provider == "local_mlx" else model_provider
7480
6718
  zero_config.setdefault("recommend", {})["model_id"] = model_id
7481
- zero_config["recommend"]["runtime"] = "mlx"
6719
+ zero_config["recommend"]["runtime"] = primary_runtime
7482
6720
  rationale = [
7483
6721
  item for item in zero_config["recommend"].get("rationale", [])
7484
6722
  if not (isinstance(item, str) and item.startswith("RAM ") and "→" in item)
7485
6723
  ]
7486
- rationale.append(f"실제 다운로드 및 로드 가능한 MLX 모델 → {model_id}")
6724
+ rationale.append(f"실제 다운로드 및 로드 가능한 {primary_runtime} 모델 → {model_id}")
7487
6725
  zero_config["recommend"]["rationale"] = rationale
7488
6726
  if isinstance(zero_config.get("plan"), dict):
6727
+ if model_provider == "ollama":
6728
+ command = ["ollama", "pull", provider_model]
6729
+ elif model_provider in {"vllm", "lmstudio", "llamacpp"}:
6730
+ command = ["lattice-ai", "models", "load", str(model_id)]
6731
+ else:
6732
+ command = ["huggingface-cli", "download", str(model_id), "--quiet"]
7489
6733
  zero_config["plan"]["steps"] = [{
7490
6734
  "name": f"weights:{model_id}",
7491
6735
  "why": "추론에 사용할 모델 가중치",
7492
- "command": ["huggingface-cli", "download", model_id, "--quiet"],
6736
+ "command": command,
7493
6737
  "requires_admin": False,
7494
6738
  }]
7495
6739
  if isinstance(zero_config.get("preset"), dict):
7496
6740
  zero_config["preset"].setdefault("model", {})["id"] = model_id
7497
- zero_config["preset"]["model"]["runtime"] = "mlx"
6741
+ zero_config["preset"]["model"]["runtime"] = primary_runtime
7498
6742
  env["zero_config"] = zero_config
7499
6743
  recs.setdefault("summary", {})["zero_config"] = zero_config["recommend"]
7500
6744
  recs["install_plan"] = zero_config["plan"]