ltcai 3.6.0 → 4.0.1
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 +39 -31
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +51 -53
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -520
- package/knowledge_graph.py +37 -4629
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +16 -17
- package/latticeai/api/agents.py +20 -7
- package/latticeai/api/auth.py +46 -15
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +139 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +5 -8
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +11 -16
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +85 -6
- package/latticeai/api/workspace.py +93 -57
- package/latticeai/app_factory.py +1781 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +66 -10
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +477 -29
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +243 -4
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +55 -16
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +10 -20
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +105 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +33 -25
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
- package/static/v3/js/core/shell.js +66 -37
- package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
- package/static/v3/js/core/store.js +11 -1
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
- package/static/v3/js/views/knowledge-graph.js +60 -44
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +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/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
- package/static/account.html +0 -115
- package/static/activity.html +0 -73
- package/static/admin.html +0 -488
- package/static/agents.html +0 -139
- package/static/chat.html +0 -844
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -124
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.2ce3815a.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
package/setup_wizard.py
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart Setup Wizard — Environment Scanner, Recommender & Auto-Installer
|
|
3
|
+
Detects hardware, tools, and API keys; returns tailored recommendations;
|
|
4
|
+
streams SSE installation progress.
|
|
5
|
+
|
|
6
|
+
Formerly the root ``setup.py``; renamed in v4 so it no longer collides with
|
|
7
|
+
the setuptools build entrypoint and actually ships in the wheel
|
|
8
|
+
(``pyproject.toml`` py-modules). Packaging is owned entirely by
|
|
9
|
+
``pyproject.toml`` — there is deliberately no root ``setup.py``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json as _json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, AsyncIterator, Dict, List, Tuple
|
|
23
|
+
|
|
24
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def _cmd(args: List[str], timeout: int = 10) -> str:
|
|
27
|
+
try:
|
|
28
|
+
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
|
|
29
|
+
return (r.stdout or r.stderr or "").strip()
|
|
30
|
+
except Exception:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
def _sse(data: Dict) -> str:
|
|
34
|
+
return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
|
|
35
|
+
|
|
36
|
+
OFFICIAL_DOWNLOADS: Dict[str, str] = {
|
|
37
|
+
"homebrew": "https://brew.sh",
|
|
38
|
+
"python": "https://www.python.org/downloads/",
|
|
39
|
+
"node": "https://nodejs.org/en/download",
|
|
40
|
+
"git": "https://git-scm.com/downloads",
|
|
41
|
+
"ollama": "https://ollama.com/download",
|
|
42
|
+
"lmstudio": "https://lmstudio.ai/download",
|
|
43
|
+
"cuda": "https://developer.nvidia.com/cuda-downloads",
|
|
44
|
+
"mlx": "https://ml-explore.github.io/mlx/build/html/install.html",
|
|
45
|
+
"cloudflared": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
46
|
+
"tesseract": "https://tesseract-ocr.github.io/tessdoc/Installation.html",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
COMMON_PATH_DIRS = [
|
|
50
|
+
"/opt/homebrew/bin",
|
|
51
|
+
"/usr/local/bin",
|
|
52
|
+
"/usr/bin",
|
|
53
|
+
"/bin",
|
|
54
|
+
str(Path.home() / ".local" / "bin"),
|
|
55
|
+
str(Path.home() / ".cargo" / "bin"),
|
|
56
|
+
str(Path.home() / ".latticeai" / "bin"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
if platform.system() == "Windows":
|
|
60
|
+
_local_appdata = os.environ.get("LOCALAPPDATA", "")
|
|
61
|
+
_program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
62
|
+
_program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
|
63
|
+
COMMON_PATH_DIRS.extend([
|
|
64
|
+
str(Path(_local_appdata) / "Programs" / "Ollama") if _local_appdata else "",
|
|
65
|
+
str(Path(_program_files) / "Ollama"),
|
|
66
|
+
str(Path(_program_files) / "LM Studio"),
|
|
67
|
+
str(Path(_program_files) / "NVIDIA Corporation" / "NVSMI"),
|
|
68
|
+
str(Path(_program_files_x86) / "NVIDIA Corporation" / "NVSMI"),
|
|
69
|
+
])
|
|
70
|
+
COMMON_PATH_DIRS = [p for p in COMMON_PATH_DIRS if p]
|
|
71
|
+
|
|
72
|
+
WINDOWS_BINARY_CANDIDATES: Dict[str, List[str]] = {
|
|
73
|
+
"ollama": [
|
|
74
|
+
str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Ollama" / "ollama.exe"),
|
|
75
|
+
str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Ollama" / "ollama.exe"),
|
|
76
|
+
],
|
|
77
|
+
"lms": [
|
|
78
|
+
str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
|
|
79
|
+
str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
|
|
80
|
+
],
|
|
81
|
+
"nvidia-smi": [
|
|
82
|
+
str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
|
|
83
|
+
str(Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
PACKAGE_MODULES: Dict[str, str] = {
|
|
88
|
+
"mlx-vlm": "mlx_vlm",
|
|
89
|
+
"huggingface_hub[cli]": "huggingface_hub",
|
|
90
|
+
"openai-whisper": "whisper",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _project_env_file() -> Path:
|
|
95
|
+
return Path(__file__).resolve().parent / ".env"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _update_env_file(env_file: Path, key: str, value: str) -> None:
|
|
99
|
+
lines: List[str] = []
|
|
100
|
+
found = False
|
|
101
|
+
if env_file.exists():
|
|
102
|
+
lines = env_file.read_text(encoding="utf-8").splitlines()
|
|
103
|
+
updated: List[str] = []
|
|
104
|
+
for line in lines:
|
|
105
|
+
if line.startswith(f"{key}="):
|
|
106
|
+
updated.append(f"{key}={value}")
|
|
107
|
+
found = True
|
|
108
|
+
else:
|
|
109
|
+
updated.append(line)
|
|
110
|
+
if not found:
|
|
111
|
+
updated.append(f"{key}={value}")
|
|
112
|
+
env_file.write_text("\n".join(updated) + "\n", encoding="utf-8")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _merge_path_dirs(dirs: List[str]) -> List[str]:
|
|
116
|
+
current = os.environ.get("PATH", "")
|
|
117
|
+
parts = [p for p in current.split(os.pathsep) if p]
|
|
118
|
+
for item in dirs:
|
|
119
|
+
expanded = str(Path(item).expanduser())
|
|
120
|
+
if Path(expanded).exists() and expanded not in parts:
|
|
121
|
+
parts.insert(0, expanded)
|
|
122
|
+
os.environ["PATH"] = os.pathsep.join(parts)
|
|
123
|
+
return parts
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _persist_extra_path(dirs: List[str]) -> None:
|
|
127
|
+
existing = [
|
|
128
|
+
p for p in os.environ.get("LATTICEAI_EXTRA_PATH", "").split(os.pathsep)
|
|
129
|
+
if p
|
|
130
|
+
]
|
|
131
|
+
merged = existing[:]
|
|
132
|
+
for item in dirs:
|
|
133
|
+
expanded = str(Path(item).expanduser())
|
|
134
|
+
if Path(expanded).exists() and expanded not in merged:
|
|
135
|
+
merged.append(expanded)
|
|
136
|
+
if merged:
|
|
137
|
+
os.environ["LATTICEAI_EXTRA_PATH"] = os.pathsep.join(merged)
|
|
138
|
+
_update_env_file(_project_env_file(), "LATTICEAI_EXTRA_PATH", os.environ["LATTICEAI_EXTRA_PATH"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def repair_path_for(binary: str | None = None) -> List[str]:
|
|
142
|
+
before = _which_any(binary) if binary else None
|
|
143
|
+
paths = _merge_path_dirs(COMMON_PATH_DIRS)
|
|
144
|
+
if binary and not before and _which_any(binary):
|
|
145
|
+
_persist_extra_path(COMMON_PATH_DIRS)
|
|
146
|
+
return paths
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _which_any(binary: str) -> str | None:
|
|
150
|
+
path = shutil.which(binary)
|
|
151
|
+
if path:
|
|
152
|
+
return path
|
|
153
|
+
if platform.system() == "Windows":
|
|
154
|
+
for candidate in WINDOWS_BINARY_CANDIDATES.get(binary, []):
|
|
155
|
+
if candidate and Path(candidate).exists():
|
|
156
|
+
return candidate
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _which_detail(binary: str) -> Dict[str, Any]:
|
|
161
|
+
path = _which_any(binary)
|
|
162
|
+
return {"installed": path is not None, "path": path}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _module_available(module_name: str) -> bool:
|
|
166
|
+
import importlib.util
|
|
167
|
+
return importlib.util.find_spec(module_name) is not None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _package_module(package: str) -> str:
|
|
171
|
+
return PACKAGE_MODULES.get(package, package.replace("-", "_").split("[", 1)[0])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _component_detail(name: str, binary: str | None = None, module: str | None = None) -> Dict[str, Any]:
|
|
175
|
+
detail: Dict[str, Any] = {"official_url": OFFICIAL_DOWNLOADS.get(name)}
|
|
176
|
+
if binary:
|
|
177
|
+
detail.update(_which_detail(binary))
|
|
178
|
+
if module:
|
|
179
|
+
detail["module_available"] = _module_available(module)
|
|
180
|
+
detail["installed"] = bool(detail.get("installed") or detail["module_available"])
|
|
181
|
+
return detail
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _verify_binary(binary: str, version_args: List[str] | None = None, timeout: int = 20) -> Tuple[bool, str]:
|
|
185
|
+
repair_path_for(binary)
|
|
186
|
+
found = _which_any(binary)
|
|
187
|
+
if not found:
|
|
188
|
+
return False, f"{binary} 실행 파일을 PATH에서 찾지 못했습니다."
|
|
189
|
+
args = [found, *(version_args or ["--version"])]
|
|
190
|
+
try:
|
|
191
|
+
completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
return False, str(e)
|
|
194
|
+
output = (completed.stdout or completed.stderr or "").strip().splitlines()
|
|
195
|
+
if completed.returncode == 0:
|
|
196
|
+
return True, output[0] if output else found
|
|
197
|
+
return False, (completed.stderr or completed.stdout or f"returncode={completed.returncode}")[-400:]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def _wait_for_binary(binary: str, seconds: int = 300) -> Tuple[bool, str]:
|
|
201
|
+
deadline = time.time() + seconds
|
|
202
|
+
while time.time() < deadline:
|
|
203
|
+
ok, msg = _verify_binary(binary)
|
|
204
|
+
if ok:
|
|
205
|
+
return True, msg
|
|
206
|
+
await asyncio.sleep(2)
|
|
207
|
+
return False, f"{binary} 설치 완료를 제한 시간 안에 감지하지 못했습니다."
|
|
208
|
+
|
|
209
|
+
# ── Environment Detection ─────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def _detect_chip() -> Dict[str, Any]:
|
|
212
|
+
arch = platform.machine()
|
|
213
|
+
is_apple = arch == "arm64" and platform.system() == "Darwin"
|
|
214
|
+
name = "Unknown CPU"
|
|
215
|
+
gen: Any = None
|
|
216
|
+
|
|
217
|
+
if is_apple:
|
|
218
|
+
profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
|
|
219
|
+
m = re.search(r"Chip:\s+(Apple M\S+)", profiler)
|
|
220
|
+
name = m.group(1) if m else "Apple Silicon"
|
|
221
|
+
gm = re.search(r"M(\d+)", name)
|
|
222
|
+
gen = int(gm.group(1)) if gm else 1
|
|
223
|
+
else:
|
|
224
|
+
brand = ""
|
|
225
|
+
if platform.system() == "Darwin":
|
|
226
|
+
brand = _cmd(["sysctl", "-n", "machdep.cpu.brand_string"])
|
|
227
|
+
elif platform.system() == "Windows":
|
|
228
|
+
raw = _cmd(["wmic", "cpu", "get", "Name", "/value"], timeout=5)
|
|
229
|
+
if "Name=" in raw:
|
|
230
|
+
brand = raw.split("Name=", 1)[-1].splitlines()[0].strip()
|
|
231
|
+
elif platform.system() == "Linux":
|
|
232
|
+
try:
|
|
233
|
+
for line in Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace").splitlines():
|
|
234
|
+
if line.lower().startswith("model name"):
|
|
235
|
+
brand = line.split(":", 1)[-1].strip()
|
|
236
|
+
break
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
name = brand or platform.processor() or "Unknown CPU"
|
|
240
|
+
|
|
241
|
+
return {"name": name, "arch": arch, "is_apple_silicon": is_apple, "gen": gen}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _detect_cpu() -> Dict[str, Any]:
|
|
245
|
+
flags: List[str] = []
|
|
246
|
+
physical_cores = os.cpu_count() or 0
|
|
247
|
+
logical_cores = os.cpu_count() or 0
|
|
248
|
+
model = _detect_chip()["name"]
|
|
249
|
+
if platform.system() == "Darwin":
|
|
250
|
+
flags = [item.lower() for item in _cmd(["sysctl", "-n", "machdep.cpu.features"], timeout=5).split()]
|
|
251
|
+
try:
|
|
252
|
+
physical_cores = int(_cmd(["sysctl", "-n", "hw.physicalcpu"], timeout=5) or physical_cores)
|
|
253
|
+
logical_cores = int(_cmd(["sysctl", "-n", "hw.logicalcpu"], timeout=5) or logical_cores)
|
|
254
|
+
except ValueError:
|
|
255
|
+
pass
|
|
256
|
+
elif platform.system() == "Linux":
|
|
257
|
+
try:
|
|
258
|
+
text = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace")
|
|
259
|
+
for line in text.splitlines():
|
|
260
|
+
if line.lower().startswith(("flags", "features")):
|
|
261
|
+
flags = line.split(":", 1)[-1].strip().lower().split()
|
|
262
|
+
break
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
elif platform.system() == "Windows":
|
|
266
|
+
raw = _cmd(["wmic", "cpu", "get", "Name,NumberOfCores,NumberOfLogicalProcessors", "/format:list"], timeout=5)
|
|
267
|
+
for line in raw.splitlines():
|
|
268
|
+
key, _, value = line.partition("=")
|
|
269
|
+
if key == "Name" and value.strip():
|
|
270
|
+
model = value.strip()
|
|
271
|
+
elif key == "NumberOfCores" and value.strip():
|
|
272
|
+
try:
|
|
273
|
+
physical_cores = int(value.strip())
|
|
274
|
+
except ValueError:
|
|
275
|
+
pass
|
|
276
|
+
elif key == "NumberOfLogicalProcessors" and value.strip():
|
|
277
|
+
try:
|
|
278
|
+
logical_cores = int(value.strip())
|
|
279
|
+
except ValueError:
|
|
280
|
+
pass
|
|
281
|
+
try:
|
|
282
|
+
import ctypes
|
|
283
|
+
kernel32 = ctypes.windll.kernel32
|
|
284
|
+
feature_map = {
|
|
285
|
+
6: "sse",
|
|
286
|
+
10: "sse2",
|
|
287
|
+
13: "sse3",
|
|
288
|
+
19: "neon",
|
|
289
|
+
28: "rdrand",
|
|
290
|
+
}
|
|
291
|
+
flags.extend(name for code, name in feature_map.items() if kernel32.IsProcessorFeaturePresent(code))
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
interesting = {"avx", "avx2", "avx512f", "fma", "neon", "sse4_2"}
|
|
295
|
+
if platform.system() == "Windows":
|
|
296
|
+
interesting.update({"sse", "sse2", "sse3", "rdrand"})
|
|
297
|
+
return {
|
|
298
|
+
"model": model,
|
|
299
|
+
"physical_cores": physical_cores,
|
|
300
|
+
"logical_cores": logical_cores,
|
|
301
|
+
"instructions": sorted({flag for flag in flags if flag in interesting}),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def _detect_ram_gb() -> float:
|
|
305
|
+
if platform.system() == "Windows":
|
|
306
|
+
raw = _cmd(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory", "/format:list"], timeout=5)
|
|
307
|
+
for line in raw.splitlines():
|
|
308
|
+
if line.startswith("TotalPhysicalMemory="):
|
|
309
|
+
try:
|
|
310
|
+
return round(int(line.split("=", 1)[-1].strip()) / 1_073_741_824, 1)
|
|
311
|
+
except ValueError:
|
|
312
|
+
break
|
|
313
|
+
raw = _cmd(["sysctl", "-n", "hw.memsize"])
|
|
314
|
+
if raw:
|
|
315
|
+
try:
|
|
316
|
+
return round(int(raw) / 1_073_741_824, 1)
|
|
317
|
+
except ValueError:
|
|
318
|
+
pass
|
|
319
|
+
if platform.system() == "Darwin":
|
|
320
|
+
profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
|
|
321
|
+
m = re.search(r"Memory:\s+([\d.]+)\s*(TB|GB|MB)", profiler, re.IGNORECASE)
|
|
322
|
+
if m:
|
|
323
|
+
value = float(m.group(1))
|
|
324
|
+
unit = m.group(2).lower()
|
|
325
|
+
if unit == "tb":
|
|
326
|
+
return round(value * 1024, 1)
|
|
327
|
+
if unit == "gb":
|
|
328
|
+
return round(value, 1)
|
|
329
|
+
return round(value / 1024, 1)
|
|
330
|
+
hostinfo = _cmd(["hostinfo"], timeout=5)
|
|
331
|
+
m = re.search(r"Primary memory available:\s+([\d.]+)\s+gigabytes", hostinfo, re.IGNORECASE)
|
|
332
|
+
if m:
|
|
333
|
+
return round(float(m.group(1)), 1)
|
|
334
|
+
try:
|
|
335
|
+
with open("/proc/meminfo") as f:
|
|
336
|
+
for line in f:
|
|
337
|
+
if line.startswith("MemTotal:"):
|
|
338
|
+
return round(int(line.split()[1]) / 1_048_576, 1)
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
return 0.0
|
|
342
|
+
|
|
343
|
+
def _detect_disk_free_gb() -> float:
|
|
344
|
+
try:
|
|
345
|
+
path = "C:\\" if platform.system() == "Windows" else "/"
|
|
346
|
+
return round(shutil.disk_usage(path).free / 1_073_741_824, 1)
|
|
347
|
+
except Exception:
|
|
348
|
+
return 0.0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _parse_windows_video_controllers(raw: str) -> List[Dict[str, Any]]:
|
|
352
|
+
controllers: List[Dict[str, Any]] = []
|
|
353
|
+
if not raw:
|
|
354
|
+
return controllers
|
|
355
|
+
try:
|
|
356
|
+
data = _json.loads(raw)
|
|
357
|
+
if isinstance(data, dict):
|
|
358
|
+
data = [data]
|
|
359
|
+
if isinstance(data, list):
|
|
360
|
+
for item in data:
|
|
361
|
+
name = str(item.get("Name") or "").strip()
|
|
362
|
+
try:
|
|
363
|
+
ram_mb = int(item.get("AdapterRAM") or 0) // (1024 * 1024)
|
|
364
|
+
except Exception:
|
|
365
|
+
ram_mb = 0
|
|
366
|
+
if name:
|
|
367
|
+
controllers.append({"name": name, "vram_mb": ram_mb})
|
|
368
|
+
if controllers:
|
|
369
|
+
return controllers
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
current: Dict[str, Any] = {}
|
|
373
|
+
for line in raw.splitlines():
|
|
374
|
+
if line.startswith("Name="):
|
|
375
|
+
if current:
|
|
376
|
+
controllers.append(current)
|
|
377
|
+
current = {"name": line.split("=", 1)[-1].strip(), "vram_mb": 0}
|
|
378
|
+
elif line.startswith("AdapterRAM=") and current:
|
|
379
|
+
try:
|
|
380
|
+
current["vram_mb"] = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
|
|
381
|
+
except ValueError:
|
|
382
|
+
current["vram_mb"] = 0
|
|
383
|
+
if current:
|
|
384
|
+
controllers.append(current)
|
|
385
|
+
return controllers
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _detect_gpu() -> Dict[str, Any]:
|
|
389
|
+
devices: List[Dict[str, Any]] = []
|
|
390
|
+
nvidia_smi = _which_any("nvidia-smi")
|
|
391
|
+
if nvidia_smi:
|
|
392
|
+
info = _cmd([nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], timeout=8)
|
|
393
|
+
for line in [item.strip() for item in info.splitlines() if item.strip()]:
|
|
394
|
+
try:
|
|
395
|
+
name, mem = [part.strip() for part in line.split(",", 1)]
|
|
396
|
+
devices.append({"vendor": "nvidia", "name": name, "vram_mb": int(float(mem)), "backend": "cuda"})
|
|
397
|
+
except Exception:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
if platform.system() == "Windows":
|
|
401
|
+
shell = _which_any("powershell") or _which_any("pwsh")
|
|
402
|
+
raw = ""
|
|
403
|
+
if shell:
|
|
404
|
+
raw = _cmd([
|
|
405
|
+
shell, "-NoProfile", "-Command",
|
|
406
|
+
"Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM | ConvertTo-Json -Compress",
|
|
407
|
+
], timeout=8)
|
|
408
|
+
if not raw:
|
|
409
|
+
raw = _cmd(["wmic", "path", "win32_VideoController", "get", "Name,AdapterRAM", "/format:list"], timeout=8)
|
|
410
|
+
for item in _parse_windows_video_controllers(raw):
|
|
411
|
+
if any(existing.get("name") == item["name"] for existing in devices):
|
|
412
|
+
continue
|
|
413
|
+
low = item["name"].lower()
|
|
414
|
+
vendor, backend = "unknown", "cpu"
|
|
415
|
+
if "nvidia" in low or "geforce" in low or "rtx" in low:
|
|
416
|
+
vendor, backend = "nvidia", "cuda"
|
|
417
|
+
elif "amd" in low or "radeon" in low:
|
|
418
|
+
vendor, backend = "amd", "directml/vulkan"
|
|
419
|
+
elif "intel" in low or "arc" in low or "iris" in low:
|
|
420
|
+
vendor, backend = "intel", "directml/vulkan"
|
|
421
|
+
devices.append({"vendor": vendor, "name": item["name"], "vram_mb": item["vram_mb"], "backend": backend})
|
|
422
|
+
elif platform.system() == "Darwin":
|
|
423
|
+
sp = _cmd(["system_profiler", "SPDisplaysDataType"], timeout=8)
|
|
424
|
+
for line in sp.splitlines():
|
|
425
|
+
if "Chipset Model" in line:
|
|
426
|
+
devices.append({"vendor": "apple", "name": line.split(":", 1)[-1].strip(), "vram_mb": 0, "backend": "metal/mlx"})
|
|
427
|
+
break
|
|
428
|
+
elif platform.system() == "Linux" and not devices:
|
|
429
|
+
info = _cmd(["lspci"], timeout=5)
|
|
430
|
+
for line in info.splitlines():
|
|
431
|
+
low = line.lower()
|
|
432
|
+
if not any(token in low for token in ("vga", "3d controller", "display")):
|
|
433
|
+
continue
|
|
434
|
+
if "nvidia" in low:
|
|
435
|
+
devices.append({"vendor": "nvidia", "name": line.strip(), "vram_mb": 0, "backend": "cuda"})
|
|
436
|
+
elif "amd" in low or "advanced micro devices" in low or "radeon" in low:
|
|
437
|
+
devices.append({"vendor": "amd", "name": line.strip(), "vram_mb": 0, "backend": "rocm/vulkan"})
|
|
438
|
+
elif "intel" in low:
|
|
439
|
+
devices.append({"vendor": "intel", "name": line.strip(), "vram_mb": 0, "backend": "vulkan"})
|
|
440
|
+
|
|
441
|
+
primary = max(devices, key=lambda item: int(item.get("vram_mb") or 0), default={})
|
|
442
|
+
vram_mb = int(primary.get("vram_mb") or 0)
|
|
443
|
+
return {
|
|
444
|
+
"devices": devices,
|
|
445
|
+
"vendor": primary.get("vendor", "none"),
|
|
446
|
+
"name": primary.get("name", ""),
|
|
447
|
+
"vram_mb": vram_mb,
|
|
448
|
+
"vram_gb": round(vram_mb / 1024, 1),
|
|
449
|
+
"backend": primary.get("backend", "cpu"),
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _detect_cuda() -> Dict[str, Any]:
|
|
454
|
+
nvidia_smi = _which_any("nvidia-smi")
|
|
455
|
+
nvcc = _which_any("nvcc")
|
|
456
|
+
version = ""
|
|
457
|
+
if nvidia_smi:
|
|
458
|
+
raw = _cmd([nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], timeout=5)
|
|
459
|
+
version = raw.splitlines()[0].strip() if raw.splitlines() else ""
|
|
460
|
+
if nvcc:
|
|
461
|
+
raw = _cmd([nvcc, "--version"], timeout=5)
|
|
462
|
+
m = re.search(r"release\s+([\d.]+)", raw)
|
|
463
|
+
if m:
|
|
464
|
+
version = m.group(1)
|
|
465
|
+
return {"available": bool(nvidia_smi or nvcc), "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _detect_wsl() -> Dict[str, Any]:
|
|
469
|
+
if platform.system() != "Linux":
|
|
470
|
+
return {"is_wsl": False, "version": ""}
|
|
471
|
+
raw = ""
|
|
472
|
+
try:
|
|
473
|
+
raw = Path("/proc/version").read_text(encoding="utf-8", errors="replace")
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
is_wsl = "microsoft" in raw.lower() or "wsl" in raw.lower()
|
|
477
|
+
version = "2" if "microsoft-standard" in raw.lower() or "wsl2" in raw.lower() else ("1" if is_wsl else "")
|
|
478
|
+
return {"is_wsl": is_wsl, "version": version}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _detect_tools() -> Dict[str, bool]:
|
|
482
|
+
repair_path_for()
|
|
483
|
+
return {t: _which_any(t) is not None
|
|
484
|
+
for t in ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"]}
|
|
485
|
+
|
|
486
|
+
def _detect_mlx() -> Dict[str, Any]:
|
|
487
|
+
return {
|
|
488
|
+
"available": _module_available("mlx"),
|
|
489
|
+
"mlx_vlm": _module_available("mlx_vlm"),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
def _detect_api_keys() -> Dict[str, bool]:
|
|
493
|
+
return {
|
|
494
|
+
"openai": bool(os.getenv("OPENAI_API_KEY")),
|
|
495
|
+
"openrouter": bool(os.getenv("OPENROUTER_API_KEY")),
|
|
496
|
+
"groq": bool(os.getenv("GROQ_API_KEY")),
|
|
497
|
+
"together": bool(os.getenv("TOGETHER_API_KEY")),
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
def scan_environment() -> Dict[str, Any]:
|
|
501
|
+
chip = _detect_chip()
|
|
502
|
+
cpu = _detect_cpu()
|
|
503
|
+
gpu = _detect_gpu()
|
|
504
|
+
cuda = _detect_cuda()
|
|
505
|
+
wsl = _detect_wsl()
|
|
506
|
+
tools = _detect_tools()
|
|
507
|
+
python_binary = "python3" if tools.get("python3") else "python"
|
|
508
|
+
return {
|
|
509
|
+
"os": platform.system(),
|
|
510
|
+
"os_version": platform.mac_ver()[0] if platform.system() == "Darwin" else platform.version(),
|
|
511
|
+
"chip": chip,
|
|
512
|
+
"cpu": cpu,
|
|
513
|
+
"gpu": gpu,
|
|
514
|
+
"cuda": cuda,
|
|
515
|
+
"wsl": wsl,
|
|
516
|
+
"ram_gb": _detect_ram_gb(),
|
|
517
|
+
"disk_free_gb": _detect_disk_free_gb(),
|
|
518
|
+
"tools": tools,
|
|
519
|
+
"components": {
|
|
520
|
+
"homebrew": _component_detail("homebrew", "brew"),
|
|
521
|
+
"python": {**_component_detail("python", python_binary), "version": platform.python_version()},
|
|
522
|
+
"node": _component_detail("node", "node"),
|
|
523
|
+
"npm": _component_detail("node", "npm"),
|
|
524
|
+
"git": _component_detail("git", "git"),
|
|
525
|
+
"ollama": _component_detail("ollama", "ollama"),
|
|
526
|
+
"lmstudio": _component_detail("lmstudio", "lms"),
|
|
527
|
+
"cuda": {**_component_detail("cuda", "nvcc"), **cuda},
|
|
528
|
+
"tesseract": _component_detail("tesseract", "tesseract"),
|
|
529
|
+
"mlx": _component_detail("mlx", module="mlx"),
|
|
530
|
+
"mlx_vlm": _component_detail("mlx", module="mlx_vlm"),
|
|
531
|
+
},
|
|
532
|
+
"path": {
|
|
533
|
+
"active": os.environ.get("PATH", ""),
|
|
534
|
+
"extra": os.environ.get("LATTICEAI_EXTRA_PATH", ""),
|
|
535
|
+
},
|
|
536
|
+
"mlx": _detect_mlx(),
|
|
537
|
+
"api_keys": _detect_api_keys(),
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
# ── Model Catalog ─────────────────────────────────────────────────────────────
|
|
541
|
+
# (model_id, display_name, size_gb, tag, description, min_ram_gb)
|
|
542
|
+
_MODEL_CATALOG = [
|
|
543
|
+
("mlx-community/Qwen3-VL-4B-Instruct-4bit", "Qwen3-VL 4B", 2.7, "VLM", "최신 Qwen 멀티모달 · 저사양", 4),
|
|
544
|
+
("mlx-community/gemma-4-e2b-it-4bit", "Gemma 4 E2B", 3.6, "VLM", "Gemma 4 소형 멀티모달", 8),
|
|
545
|
+
("mlx-community/Qwen3-VL-8B-Instruct-4bit", "Qwen3-VL 8B", 4.8, "VLM", "최신 Qwen 멀티모달 · 균형 추천", 16),
|
|
546
|
+
("mlx-community/gemma-4-12b-it-4bit", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
|
|
547
|
+
("mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit", "Llama 4 Scout", 11.8, "VLM", "Meta 최신 멀티모달 Scout", 24),
|
|
548
|
+
("mlx-community/gemma-4-26b-a4b-it-4bit", "Gemma 4 26B", 15.6, "VLM", "이미지 지원 · 대형 추천", 32),
|
|
549
|
+
("mlx-community/gemma-4-31b-it-4bit", "Gemma 4 31B", 18.4, "VLM+", "Gemma 4 최신 31B instruct", 48),
|
|
550
|
+
("mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "Qwen3-VL 30B A3B", 18.0, "VLM+", "최신 Qwen 대형 멀티모달", 48),
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
_CROSS_PLATFORM_MODEL_CATALOG: Dict[str, List[Tuple[str, str, float, str, str, int]]] = {
|
|
554
|
+
"ollama": [
|
|
555
|
+
("ollama:qwen3-vl:4b", "Qwen3-VL 4B", 2.7, "VLM", "Ollama 멀티모달 · 저사양", 4),
|
|
556
|
+
("ollama:qwen3-vl:8b", "Qwen3-VL 8B", 4.8, "VLM", "Ollama 멀티모달 · 균형 추천", 16),
|
|
557
|
+
("ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M", "Gemma 4 12B Q4", 7.9, "VLM", "Hugging Face GGUF 기반 Gemma 4", 16),
|
|
558
|
+
("ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M", "Gemma 4 31B Q4", 18.7, "VLM+", "Hugging Face GGUF 기반 Gemma 4", 48),
|
|
559
|
+
("ollama:hf.co/ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF:Q4_K_M", "Llama 4 Scout Q4", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
|
|
560
|
+
],
|
|
561
|
+
"lmstudio": [
|
|
562
|
+
("lmstudio:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "LM Studio 멀티모달 · 저사양", 4),
|
|
563
|
+
("lmstudio:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "LM Studio 멀티모달 · 균형 추천", 16),
|
|
564
|
+
("lmstudio:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B 4-bit", 7.9, "VLM", "LM Studio GGUF Gemma 4", 16),
|
|
565
|
+
("lmstudio:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B 4-bit", 18.7, "VLM+", "LM Studio GGUF Gemma 4", 48),
|
|
566
|
+
("lmstudio:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
|
|
567
|
+
("lmstudio:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
|
|
568
|
+
],
|
|
569
|
+
"vllm": [
|
|
570
|
+
("vllm:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "내 컴퓨터 GPU 실행 도구 권장", 4),
|
|
571
|
+
("vllm:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "내 컴퓨터 NVIDIA 실행 도구 권장", 16),
|
|
572
|
+
("vllm:google/gemma-4-12b-it", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
|
|
573
|
+
("vllm:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
|
|
574
|
+
("vllm:suitch/gemma-4-31B-it-4bit", "Gemma 4 31B", 18.7, "VLM+", "Gemma 4 최신 31B instruct", 48),
|
|
575
|
+
("vllm:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
|
|
576
|
+
],
|
|
577
|
+
"llamacpp": [
|
|
578
|
+
("llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF", "Qwen3-VL 4B GGUF", 2.7, "GGUF", "CPU/Vulkan 백업 · 멀티모달 GGUF", 4),
|
|
579
|
+
("llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF", "Qwen3-VL 8B GGUF", 4.8, "GGUF", "CPU/Vulkan 백업 · 균형형", 16),
|
|
580
|
+
("llamacpp:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B GGUF", 7.9, "GGUF", "Gemma 4 12B Q4_K_M", 16),
|
|
581
|
+
("llamacpp:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B GGUF", 18.7, "GGUF", "Gemma 4 31B Q4_K_M", 48),
|
|
582
|
+
("llamacpp:ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF", "Llama 4 Scout GGUF", 12.0, "GGUF", "Meta 최신 멀티모달 Scout", 24),
|
|
583
|
+
],
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_VERSIONED_MODEL_PATTERNS = (
|
|
587
|
+
("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
|
|
588
|
+
("qwen", re.compile(r"\bqwen[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
|
|
589
|
+
("llama", re.compile(r"\bllama[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
_BEST_MODEL_TIERS: Dict[str, List[Tuple[int, str]]] = {
|
|
593
|
+
"local_mlx": [
|
|
594
|
+
(48, "mlx-community/gemma-4-31b-it-4bit"),
|
|
595
|
+
(32, "mlx-community/gemma-4-26b-a4b-it-4bit"),
|
|
596
|
+
(16, "mlx-community/gemma-4-12b-it-4bit"),
|
|
597
|
+
(16, "mlx-community/Qwen3-VL-8B-Instruct-4bit"),
|
|
598
|
+
(4, "mlx-community/Qwen3-VL-4B-Instruct-4bit"),
|
|
599
|
+
],
|
|
600
|
+
"ollama": [
|
|
601
|
+
(48, "ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M"),
|
|
602
|
+
(16, "ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M"),
|
|
603
|
+
(16, "ollama:qwen3-vl:8b"),
|
|
604
|
+
(4, "ollama:qwen3-vl:4b"),
|
|
605
|
+
],
|
|
606
|
+
"lmstudio": [
|
|
607
|
+
(48, "lmstudio:ggml-org/gemma-4-31B-it-GGUF"),
|
|
608
|
+
(16, "lmstudio:ggml-org/gemma-4-12B-it-GGUF"),
|
|
609
|
+
(16, "lmstudio:Qwen/Qwen3-VL-8B-Instruct"),
|
|
610
|
+
(4, "lmstudio:Qwen/Qwen3-VL-4B-Instruct"),
|
|
611
|
+
],
|
|
612
|
+
"vllm": [
|
|
613
|
+
(48, "vllm:suitch/gemma-4-31B-it-4bit"),
|
|
614
|
+
(16, "vllm:google/gemma-4-12b-it"),
|
|
615
|
+
(16, "vllm:Qwen/Qwen3-VL-8B-Instruct"),
|
|
616
|
+
(4, "vllm:Qwen/Qwen3-VL-4B-Instruct"),
|
|
617
|
+
],
|
|
618
|
+
"llamacpp": [
|
|
619
|
+
(48, "llamacpp:ggml-org/gemma-4-31B-it-GGUF"),
|
|
620
|
+
(16, "llamacpp:ggml-org/gemma-4-12B-it-GGUF"),
|
|
621
|
+
(16, "llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF"),
|
|
622
|
+
(4, "llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF"),
|
|
623
|
+
],
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _version_tuple(raw: str) -> Tuple[int, ...]:
|
|
628
|
+
return tuple(int(part) for part in raw.split(".") if part.isdigit())
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _catalog_row_family_version(row: Tuple[str, str, float, str, str, int]) -> Tuple[str, Tuple[int, ...]] | None:
|
|
632
|
+
text = f"{row[0]} {row[1]}"
|
|
633
|
+
for family, pattern in _VERSIONED_MODEL_PATTERNS:
|
|
634
|
+
match = pattern.search(text)
|
|
635
|
+
if match:
|
|
636
|
+
version = _version_tuple(match.group(1))
|
|
637
|
+
if version:
|
|
638
|
+
return family, version
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _filter_lower_family_versions(
|
|
643
|
+
rows: List[Tuple[str, str, float, str, str, int]],
|
|
644
|
+
) -> List[Tuple[str, str, float, str, str, int]]:
|
|
645
|
+
max_versions: Dict[str, Tuple[int, ...]] = {}
|
|
646
|
+
detected: List[Tuple[Tuple[str, str, float, str, str, int], Tuple[str, Tuple[int, ...]] | None]] = []
|
|
647
|
+
for row in rows:
|
|
648
|
+
version_info = _catalog_row_family_version(row)
|
|
649
|
+
detected.append((row, version_info))
|
|
650
|
+
if not version_info:
|
|
651
|
+
continue
|
|
652
|
+
family, version = version_info
|
|
653
|
+
if version > max_versions.get(family, (0,)):
|
|
654
|
+
max_versions[family] = version
|
|
655
|
+
return [
|
|
656
|
+
row for row, version_info in detected
|
|
657
|
+
if not version_info or version_info[1] >= max_versions.get(version_info[0], version_info[1])
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _best_model_for_engine(engine: str, ram_gb: float, rows: List[Tuple[str, str, float, str, str, int]]) -> str:
|
|
662
|
+
available_ids = {row[0] for row in rows}
|
|
663
|
+
for min_ram, model_id in _BEST_MODEL_TIERS.get(engine, []):
|
|
664
|
+
if ram_gb >= min_ram and model_id in available_ids:
|
|
665
|
+
return model_id
|
|
666
|
+
return rows[0][0] if rows else ""
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ── Recommendation Logic ──────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
|
|
672
|
+
ram = env["ram_gb"]
|
|
673
|
+
chip = env["chip"]
|
|
674
|
+
mlx = env["mlx"]
|
|
675
|
+
tools = env["tools"]
|
|
676
|
+
api_keys = env["api_keys"]
|
|
677
|
+
disk_free = env["disk_free_gb"]
|
|
678
|
+
is_apple = chip["is_apple_silicon"]
|
|
679
|
+
gpu = env.get("gpu", {})
|
|
680
|
+
cuda = env.get("cuda", {})
|
|
681
|
+
wsl = env.get("wsl", {})
|
|
682
|
+
cpu = env.get("cpu", {})
|
|
683
|
+
os_name = env.get("os", "")
|
|
684
|
+
|
|
685
|
+
max_model_gb = ram * 0.72 # ~28% headroom for OS + apps
|
|
686
|
+
|
|
687
|
+
if is_apple:
|
|
688
|
+
preferred_engine = "local_mlx"
|
|
689
|
+
elif gpu.get("vendor") == "nvidia" and cuda.get("available") and (os_name == "Linux" or wsl.get("is_wsl")):
|
|
690
|
+
preferred_engine = "vllm"
|
|
691
|
+
elif tools.get("lms"):
|
|
692
|
+
preferred_engine = "lmstudio"
|
|
693
|
+
elif tools.get("ollama"):
|
|
694
|
+
preferred_engine = "ollama"
|
|
695
|
+
else:
|
|
696
|
+
preferred_engine = "llamacpp"
|
|
697
|
+
|
|
698
|
+
apple_catalog = _filter_lower_family_versions(_MODEL_CATALOG)
|
|
699
|
+
engine_catalog = (
|
|
700
|
+
[]
|
|
701
|
+
if is_apple
|
|
702
|
+
else _filter_lower_family_versions(_CROSS_PLATFORM_MODEL_CATALOG[preferred_engine])
|
|
703
|
+
)
|
|
704
|
+
best_id = _best_model_for_engine(
|
|
705
|
+
"local_mlx" if is_apple else preferred_engine,
|
|
706
|
+
ram,
|
|
707
|
+
apple_catalog if is_apple else engine_catalog,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# ── Engines ──────────────────────────────────────────────────────────────
|
|
711
|
+
engines: List[Dict] = []
|
|
712
|
+
|
|
713
|
+
if is_apple:
|
|
714
|
+
if mlx["available"] and mlx["mlx_vlm"]:
|
|
715
|
+
engines.append({
|
|
716
|
+
"id": "engine_mlx", "name": "MLX",
|
|
717
|
+
"subtitle": f"{chip['name']} GPU 가속 · MLX-VLM 멀티모달 실행",
|
|
718
|
+
"status": "installed", "priority": "recommended",
|
|
719
|
+
"checked": True, "action": None, "badge": "설치됨",
|
|
720
|
+
})
|
|
721
|
+
else:
|
|
722
|
+
engines.append({
|
|
723
|
+
"id": "engine_mlx", "name": "MLX",
|
|
724
|
+
"subtitle": f"{chip['name']} 전용 MLX-VLM 멀티모달 실행",
|
|
725
|
+
"status": "available", "priority": "recommended",
|
|
726
|
+
"checked": True,
|
|
727
|
+
"action": {"type": "pip", "packages": ["mlx-vlm"], "verify_modules": ["mlx", "mlx_vlm"]},
|
|
728
|
+
"badge": "설치 필요",
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
if tools.get("ollama"):
|
|
732
|
+
engines.append({
|
|
733
|
+
"id": "engine_ollama", "name": "Ollama",
|
|
734
|
+
"subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
|
|
735
|
+
"status": "installed", "priority": "recommended" if preferred_engine == "ollama" else "optional",
|
|
736
|
+
"checked": preferred_engine == "ollama", "action": None, "badge": "설치됨",
|
|
737
|
+
})
|
|
738
|
+
else:
|
|
739
|
+
hint = "brew install 가능" if (tools.get("brew") or env["os"] == "Darwin") else "수동 설치 필요"
|
|
740
|
+
engines.append({
|
|
741
|
+
"id": "engine_ollama", "name": "Ollama",
|
|
742
|
+
"subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
|
|
743
|
+
"status": "available", "priority": "recommended" if preferred_engine == "ollama" else "optional",
|
|
744
|
+
"checked": preferred_engine == "ollama",
|
|
745
|
+
"action": (
|
|
746
|
+
{"type": "brew", "package": "ollama", "binary": "ollama", "official_url": OFFICIAL_DOWNLOADS["ollama"]}
|
|
747
|
+
if tools.get("brew")
|
|
748
|
+
else {"type": "url", "url": OFFICIAL_DOWNLOADS["ollama"], "binary": "ollama"}
|
|
749
|
+
),
|
|
750
|
+
"badge": hint,
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
if not is_apple:
|
|
754
|
+
lmstudio_installed = bool(tools.get("lms"))
|
|
755
|
+
engines.append({
|
|
756
|
+
"id": "engine_lmstudio", "name": "LM Studio",
|
|
757
|
+
"subtitle": "Windows/macOS/Linux 데스크톱 GPU 서버 · 모델 다운로드 UI 포함",
|
|
758
|
+
"status": "installed" if lmstudio_installed else "available",
|
|
759
|
+
"priority": "recommended" if preferred_engine == "lmstudio" else "optional",
|
|
760
|
+
"checked": preferred_engine == "lmstudio",
|
|
761
|
+
"action": None if lmstudio_installed else {"type": "url", "url": OFFICIAL_DOWNLOADS["lmstudio"], "binary": "lms"},
|
|
762
|
+
"badge": "설치됨" if lmstudio_installed else "설치 필요",
|
|
763
|
+
})
|
|
764
|
+
if gpu.get("vendor") == "nvidia":
|
|
765
|
+
engines.append({
|
|
766
|
+
"id": "engine_cuda", "name": "CUDA",
|
|
767
|
+
"subtitle": f"NVIDIA {gpu.get('name') or 'GPU'} · VRAM {gpu.get('vram_gb') or 0} GB",
|
|
768
|
+
"status": "installed" if cuda.get("available") else "available",
|
|
769
|
+
"priority": "recommended",
|
|
770
|
+
"checked": False,
|
|
771
|
+
"action": None if cuda.get("available") else {"type": "url", "url": OFFICIAL_DOWNLOADS["cuda"], "binary": "nvcc"},
|
|
772
|
+
"badge": cuda.get("version") or ("감지됨" if cuda.get("available") else "설치 필요"),
|
|
773
|
+
})
|
|
774
|
+
engines.append({
|
|
775
|
+
"id": "engine_vllm", "name": "vLLM",
|
|
776
|
+
"subtitle": "NVIDIA 서버형 추론 · Windows는 WSL/Linux 권장",
|
|
777
|
+
"status": "available",
|
|
778
|
+
"priority": "recommended" if preferred_engine == "vllm" else "optional",
|
|
779
|
+
"checked": preferred_engine == "vllm",
|
|
780
|
+
"action": {"type": "pip", "packages": ["vllm", "huggingface_hub[cli]"], "verify_modules": ["vllm", "huggingface_hub"]},
|
|
781
|
+
"badge": "WSL/Linux 권장" if os_name == "Windows" and not wsl.get("is_wsl") else "설치 가능",
|
|
782
|
+
})
|
|
783
|
+
elif gpu.get("vendor") in {"amd", "intel"}:
|
|
784
|
+
engines.append({
|
|
785
|
+
"id": "engine_vulkan_directml", "name": "Vulkan/DirectML",
|
|
786
|
+
"subtitle": f"{gpu.get('vendor', '').upper()} GPU 감지 · LM Studio 또는 llama.cpp 백엔드 권장",
|
|
787
|
+
"status": "available",
|
|
788
|
+
"priority": "recommended" if preferred_engine in {"lmstudio", "llamacpp"} else "optional",
|
|
789
|
+
"checked": False,
|
|
790
|
+
"action": None,
|
|
791
|
+
"badge": gpu.get("backend") or "GPU",
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
components: List[Dict] = []
|
|
795
|
+
component_specs = [
|
|
796
|
+
("homebrew", "Homebrew", "macOS 패키지 관리자 · 자동 설치 기반", "brew", None, "recommended"),
|
|
797
|
+
("git", "Git", "저장소 · 확장 · MCP 도구 연동에 필요", "git", "git", "recommended"),
|
|
798
|
+
("node", "Node.js", "npm 패키지와 VS Code 확장 개발에 필요", "node", "node", "optional"),
|
|
799
|
+
("tesseract", "Tesseract OCR", "이미지/PDF OCR 기능에 필요", "tesseract", "tesseract", "optional"),
|
|
800
|
+
]
|
|
801
|
+
for cid, name, subtitle, binary, brew_pkg, priority in component_specs:
|
|
802
|
+
installed = bool(tools.get(binary))
|
|
803
|
+
if cid == "homebrew" and env["os"] != "Darwin":
|
|
804
|
+
continue
|
|
805
|
+
if installed:
|
|
806
|
+
components.append({
|
|
807
|
+
"id": f"component_{cid}", "name": name,
|
|
808
|
+
"subtitle": subtitle, "status": "installed",
|
|
809
|
+
"priority": priority, "checked": False, "action": None,
|
|
810
|
+
"badge": "설치됨",
|
|
811
|
+
})
|
|
812
|
+
continue
|
|
813
|
+
if cid == "homebrew":
|
|
814
|
+
action = {"type": "url", "url": OFFICIAL_DOWNLOADS["homebrew"], "binary": "brew"}
|
|
815
|
+
elif tools.get("brew") and brew_pkg:
|
|
816
|
+
action = {"type": "brew", "package": brew_pkg, "binary": binary, "official_url": OFFICIAL_DOWNLOADS.get(cid)}
|
|
817
|
+
else:
|
|
818
|
+
action = {"type": "url", "url": OFFICIAL_DOWNLOADS.get(cid, ""), "binary": binary}
|
|
819
|
+
components.append({
|
|
820
|
+
"id": f"component_{cid}", "name": name,
|
|
821
|
+
"subtitle": subtitle, "status": "available",
|
|
822
|
+
"priority": priority, "checked": priority == "recommended",
|
|
823
|
+
"action": action, "badge": "설치 필요",
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
python_ok = sys.version_info >= (3, 11)
|
|
827
|
+
if not python_ok:
|
|
828
|
+
components.insert(0, {
|
|
829
|
+
"id": "component_python", "name": "Python 3.11+",
|
|
830
|
+
"subtitle": "Lattice AI 서버 실행에 필요한 Python 런타임",
|
|
831
|
+
"status": "available", "priority": "recommended", "checked": True,
|
|
832
|
+
"action": {"type": "url", "url": OFFICIAL_DOWNLOADS["python"], "binary": "python3"},
|
|
833
|
+
"badge": "업데이트 필요",
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
for provider, has_key in api_keys.items():
|
|
837
|
+
if has_key:
|
|
838
|
+
engines.append({
|
|
839
|
+
"id": f"engine_{provider}", "name": provider.title(),
|
|
840
|
+
"subtitle": f"{provider.upper()}_API_KEY 감지됨 · 클라우드 API",
|
|
841
|
+
"status": "ready", "priority": "optional",
|
|
842
|
+
"checked": False, "action": None, "badge": "준비됨",
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
# ── Models ───────────────────────────────────────────────────────────────
|
|
846
|
+
models: List[Dict] = []
|
|
847
|
+
|
|
848
|
+
if is_apple:
|
|
849
|
+
for mid, mname, size_gb, tag, desc, min_ram in apple_catalog:
|
|
850
|
+
fits = ram >= min_ram and size_gb <= max_model_gb and disk_free >= size_gb + 2
|
|
851
|
+
is_best = mid == best_id
|
|
852
|
+
models.append({
|
|
853
|
+
"id": f"model_{mid.replace('/', '__').replace('-', '_')}",
|
|
854
|
+
"model_id": mid,
|
|
855
|
+
"name": mname,
|
|
856
|
+
"subtitle": desc,
|
|
857
|
+
"size_gb": size_gb,
|
|
858
|
+
"tag": tag,
|
|
859
|
+
"fits": fits,
|
|
860
|
+
"priority": "recommended" if is_best else "optional",
|
|
861
|
+
"checked": is_best and fits,
|
|
862
|
+
"disabled": not fits,
|
|
863
|
+
"badge": f"{size_gb} GB",
|
|
864
|
+
"action": {"type": "load_model", "model_id": mid} if fits else None,
|
|
865
|
+
})
|
|
866
|
+
else:
|
|
867
|
+
vram_gb = float(gpu.get("vram_gb") or 0)
|
|
868
|
+
gpu_budget_gb = vram_gb * 1.15 if gpu.get("vendor") in {"nvidia", "amd", "intel"} and vram_gb else max_model_gb
|
|
869
|
+
model_budget_gb = min(max_model_gb, gpu_budget_gb)
|
|
870
|
+
for mid, mname, size_gb, tag, desc, min_ram in engine_catalog:
|
|
871
|
+
fits = ram >= min_ram and size_gb <= model_budget_gb and disk_free >= size_gb + 2
|
|
872
|
+
is_best = mid == best_id
|
|
873
|
+
models.append({
|
|
874
|
+
"id": f"model_{mid.replace('/', '__').replace(':', '__').replace('-', '_')}",
|
|
875
|
+
"model_id": mid,
|
|
876
|
+
"name": mname,
|
|
877
|
+
"subtitle": desc,
|
|
878
|
+
"size_gb": size_gb,
|
|
879
|
+
"tag": tag,
|
|
880
|
+
"fits": fits,
|
|
881
|
+
"priority": "recommended" if is_best else "optional",
|
|
882
|
+
"checked": is_best and fits,
|
|
883
|
+
"disabled": not fits,
|
|
884
|
+
"badge": f"{size_gb} GB · {preferred_engine}",
|
|
885
|
+
"action": {"type": "load_model", "model_id": mid} if fits else None,
|
|
886
|
+
})
|
|
887
|
+
if models and not any(item.get("checked") for item in models):
|
|
888
|
+
for item in models:
|
|
889
|
+
if not item.get("disabled"):
|
|
890
|
+
item["priority"] = "recommended"
|
|
891
|
+
item["checked"] = True
|
|
892
|
+
break
|
|
893
|
+
|
|
894
|
+
# ── MCPs ─────────────────────────────────────────────────────────────────
|
|
895
|
+
mcps: List[Dict] = [
|
|
896
|
+
{
|
|
897
|
+
"id": "mcp_files", "name": "Workspace Files",
|
|
898
|
+
"subtitle": "파일 읽기/쓰기 · 코드 생성 · 미리보기",
|
|
899
|
+
"status": "active", "priority": "recommended",
|
|
900
|
+
"checked": True, "action": None,
|
|
901
|
+
"badge": "기본 탑재", "needs_auth": False,
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
"id": "mcp_presentations", "name": "Presentations",
|
|
905
|
+
"subtitle": "PPTX · 슬라이드 자동 생성",
|
|
906
|
+
"status": "active", "priority": "optional",
|
|
907
|
+
"checked": False, "action": None,
|
|
908
|
+
"badge": "기본 탑재", "needs_auth": False,
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
"id": "mcp_github", "name": "GitHub",
|
|
912
|
+
"subtitle": "저장소 · PR · 이슈 · CI 연동",
|
|
913
|
+
"status": "available", "priority": "optional",
|
|
914
|
+
"checked": False,
|
|
915
|
+
"action": {"type": "auth", "url": "https://github.com/apps", "mcp_id": "github"},
|
|
916
|
+
"badge": "인증 필요", "needs_auth": True,
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
"id": "mcp_googledrive", "name": "Google Drive",
|
|
920
|
+
"subtitle": "Docs · Sheets · Drive 파일 연동",
|
|
921
|
+
"status": "available", "priority": "optional",
|
|
922
|
+
"checked": False,
|
|
923
|
+
"action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "google-drive"},
|
|
924
|
+
"badge": "인증 필요", "needs_auth": True,
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
"id": "mcp_slack", "name": "Slack",
|
|
928
|
+
"subtitle": "팀 채널 공유 · 알림 워크플로",
|
|
929
|
+
"status": "available", "priority": "optional",
|
|
930
|
+
"checked": False,
|
|
931
|
+
"action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "slack"},
|
|
932
|
+
"badge": "인증 필요", "needs_auth": True,
|
|
933
|
+
},
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
"components": components,
|
|
938
|
+
"engines": engines,
|
|
939
|
+
"models": models,
|
|
940
|
+
"mcps": mcps,
|
|
941
|
+
"summary": {
|
|
942
|
+
"chip": chip["name"],
|
|
943
|
+
"cpu_cores": cpu.get("logical_cores"),
|
|
944
|
+
"cpu_instructions": cpu.get("instructions", []),
|
|
945
|
+
"gpu": gpu.get("name") or gpu.get("vendor"),
|
|
946
|
+
"gpu_vendor": gpu.get("vendor"),
|
|
947
|
+
"vram_gb": gpu.get("vram_gb"),
|
|
948
|
+
"cuda": cuda.get("available"),
|
|
949
|
+
"cuda_version": cuda.get("version"),
|
|
950
|
+
"wsl": wsl,
|
|
951
|
+
"preferred_engine": preferred_engine,
|
|
952
|
+
"ram_gb": ram,
|
|
953
|
+
"disk_free_gb": disk_free,
|
|
954
|
+
"is_apple_silicon": is_apple,
|
|
955
|
+
"max_model_gb": round(max_model_gb, 1),
|
|
956
|
+
},
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
# ── Installation Stream ───────────────────────────────────────────────────────
|
|
960
|
+
|
|
961
|
+
def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
|
|
962
|
+
atype = action.get("type")
|
|
963
|
+
if atype == "pip":
|
|
964
|
+
modules = action.get("verify_modules") or [_package_module(pkg) for pkg in action.get("packages", [])]
|
|
965
|
+
missing = [module for module in modules if not _module_available(module)]
|
|
966
|
+
if missing:
|
|
967
|
+
return False, "Python 모듈 감지 실패: " + ", ".join(missing)
|
|
968
|
+
return True, "Python 모듈 import 테스트 통과"
|
|
969
|
+
binary = action.get("binary")
|
|
970
|
+
if binary:
|
|
971
|
+
return _verify_binary(binary)
|
|
972
|
+
return True, "검증 항목 없음"
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
async def _repair_action(action: Dict[str, Any]) -> Tuple[bool, str]:
|
|
976
|
+
binary = action.get("binary")
|
|
977
|
+
if binary:
|
|
978
|
+
repair_path_for(binary)
|
|
979
|
+
ok, msg = _verify_binary(binary)
|
|
980
|
+
if ok:
|
|
981
|
+
return True, f"PATH 자동 보정 완료: {msg}"
|
|
982
|
+
if action.get("type") == "pip":
|
|
983
|
+
packages = action.get("packages", [])
|
|
984
|
+
if packages:
|
|
985
|
+
for pkg in packages:
|
|
986
|
+
success, err = await _pip_install(pkg)
|
|
987
|
+
if not success:
|
|
988
|
+
return False, err
|
|
989
|
+
return _verify_action(action)
|
|
990
|
+
return False, "자동 복구 방법을 찾지 못했습니다."
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
|
|
994
|
+
for item in items:
|
|
995
|
+
item_id = item.get("id", "unknown")
|
|
996
|
+
name = item.get("name", item_id)
|
|
997
|
+
action = item.get("action") or {}
|
|
998
|
+
atype = action.get("type")
|
|
999
|
+
|
|
1000
|
+
if not atype:
|
|
1001
|
+
yield _sse({"id": item_id, "status": "skipped", "msg": f"{name} — 이미 준비됨"})
|
|
1002
|
+
await asyncio.sleep(0.04)
|
|
1003
|
+
continue
|
|
1004
|
+
|
|
1005
|
+
yield _sse({"id": item_id, "status": "starting", "msg": f"{name} 준비 중..."})
|
|
1006
|
+
|
|
1007
|
+
if atype == "pip":
|
|
1008
|
+
packages = action.get("packages", [])
|
|
1009
|
+
ok = True
|
|
1010
|
+
for pkg in packages:
|
|
1011
|
+
yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
|
|
1012
|
+
success, err = await _pip_install(pkg)
|
|
1013
|
+
if success:
|
|
1014
|
+
yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
|
|
1015
|
+
else:
|
|
1016
|
+
yield _sse({"id": item_id, "status": "error", "msg": f"{pkg} 실패: {err[:400]}"})
|
|
1017
|
+
ok = False
|
|
1018
|
+
break
|
|
1019
|
+
if ok:
|
|
1020
|
+
yield _sse({"id": item_id, "status": "running", "msg": f"{name} 동작 테스트 중..."})
|
|
1021
|
+
verified, detail = _verify_action(action)
|
|
1022
|
+
if verified:
|
|
1023
|
+
yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
|
|
1024
|
+
else:
|
|
1025
|
+
yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
|
|
1026
|
+
repaired, repair_msg = await _repair_action(action)
|
|
1027
|
+
yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
|
|
1028
|
+
|
|
1029
|
+
elif atype == "brew":
|
|
1030
|
+
pkg = action.get("package", "")
|
|
1031
|
+
yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
|
|
1032
|
+
success, err = await _brew_install(pkg)
|
|
1033
|
+
if success:
|
|
1034
|
+
yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
|
|
1035
|
+
binary = action.get("binary")
|
|
1036
|
+
if binary:
|
|
1037
|
+
repair_path_for(binary)
|
|
1038
|
+
verified, detail = _verify_action(action)
|
|
1039
|
+
if verified:
|
|
1040
|
+
yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
|
|
1041
|
+
else:
|
|
1042
|
+
yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
|
|
1043
|
+
repaired, repair_msg = await _repair_action(action)
|
|
1044
|
+
yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
|
|
1045
|
+
else:
|
|
1046
|
+
url = action.get("official_url") or action.get("url")
|
|
1047
|
+
if url:
|
|
1048
|
+
yield _sse({"id": item_id, "status": "auth", "msg": f"자동 설치 실패 — 공식 다운로드 페이지를 엽니다.\n{err[:240]}", "auth_url": url})
|
|
1049
|
+
open_url(url)
|
|
1050
|
+
yield _sse({"id": item_id, "status": "error", "msg": f"실패: {err[:400]}"})
|
|
1051
|
+
|
|
1052
|
+
elif atype == "load_model":
|
|
1053
|
+
model_id = action.get("model_id", "")
|
|
1054
|
+
yield _sse({"id": item_id, "status": "running",
|
|
1055
|
+
"msg": f"모델 다운로드 · 로딩 중...\n{model_id}\n(용량에 따라 수 분 소요)"})
|
|
1056
|
+
try:
|
|
1057
|
+
msg = await router.load_model(model_id)
|
|
1058
|
+
yield _sse({"id": item_id, "status": "done", "msg": f"{name} 로드 완료 ✅"})
|
|
1059
|
+
except Exception as e:
|
|
1060
|
+
yield _sse({"id": item_id, "status": "error", "msg": f"로드 실패: {str(e)[:400]}"})
|
|
1061
|
+
|
|
1062
|
+
elif atype == "auth":
|
|
1063
|
+
url = action.get("url", "")
|
|
1064
|
+
yield _sse({"id": item_id, "status": "auth",
|
|
1065
|
+
"msg": "브라우저에서 인증 페이지를 엽니다...", "auth_url": url})
|
|
1066
|
+
open_url(url)
|
|
1067
|
+
yield _sse({"id": item_id, "status": "waiting",
|
|
1068
|
+
"msg": "브라우저에서 인증 완료 후 계속하세요"})
|
|
1069
|
+
|
|
1070
|
+
elif atype == "url":
|
|
1071
|
+
url = action.get("url", "")
|
|
1072
|
+
yield _sse({"id": item_id, "status": "auth",
|
|
1073
|
+
"msg": "설치 페이지를 브라우저에서 엽니다...", "auth_url": url})
|
|
1074
|
+
open_url(url)
|
|
1075
|
+
binary = action.get("binary")
|
|
1076
|
+
if binary:
|
|
1077
|
+
yield _sse({"id": item_id, "status": "waiting",
|
|
1078
|
+
"msg": f"{binary} 설치 완료를 자동 감지하는 중입니다..."})
|
|
1079
|
+
ok, detail = await _wait_for_binary(binary)
|
|
1080
|
+
if ok:
|
|
1081
|
+
repair_path_for(binary)
|
|
1082
|
+
yield _sse({"id": item_id, "status": "done",
|
|
1083
|
+
"msg": f"{name} 설치 · PATH 연결 · 검증 완료 ✅\n{detail}"})
|
|
1084
|
+
else:
|
|
1085
|
+
yield _sse({"id": item_id, "status": "error",
|
|
1086
|
+
"msg": f"{detail}\n공식 페이지에서 설치 후 다시 시도하세요."})
|
|
1087
|
+
else:
|
|
1088
|
+
yield _sse({"id": item_id, "status": "waiting",
|
|
1089
|
+
"msg": "브라우저에서 설치 또는 인증을 완료한 뒤 다시 시도하세요"})
|
|
1090
|
+
|
|
1091
|
+
else:
|
|
1092
|
+
yield _sse({"id": item_id, "status": "error", "msg": f"알 수 없는 액션: {atype}"})
|
|
1093
|
+
|
|
1094
|
+
yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
async def _pip_install(package: str) -> Tuple[bool, str]:
|
|
1098
|
+
try:
|
|
1099
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1100
|
+
sys.executable, "-m", "pip", "install", "--upgrade", package,
|
|
1101
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1102
|
+
)
|
|
1103
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
|
|
1104
|
+
if proc.returncode == 0:
|
|
1105
|
+
return True, ""
|
|
1106
|
+
return False, stderr.decode(errors="replace")
|
|
1107
|
+
except asyncio.TimeoutError:
|
|
1108
|
+
return False, "설치 시간 초과 (10분)"
|
|
1109
|
+
except Exception as e:
|
|
1110
|
+
return False, str(e)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
async def _brew_install(package: str) -> Tuple[bool, str]:
|
|
1114
|
+
brew = shutil.which("brew")
|
|
1115
|
+
if not brew:
|
|
1116
|
+
return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
|
|
1117
|
+
try:
|
|
1118
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1119
|
+
brew, "install", package,
|
|
1120
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1121
|
+
)
|
|
1122
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
|
1123
|
+
if proc.returncode == 0:
|
|
1124
|
+
return True, ""
|
|
1125
|
+
return False, stderr.decode(errors="replace")
|
|
1126
|
+
except asyncio.TimeoutError:
|
|
1127
|
+
return False, "설치 시간 초과 (5분)"
|
|
1128
|
+
except Exception as e:
|
|
1129
|
+
return False, str(e)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def open_url(url: str) -> None:
|
|
1133
|
+
try:
|
|
1134
|
+
system = platform.system()
|
|
1135
|
+
if system == "Darwin":
|
|
1136
|
+
subprocess.Popen(["open", url])
|
|
1137
|
+
elif system == "Windows":
|
|
1138
|
+
subprocess.Popen(["start", "", url], shell=True)
|
|
1139
|
+
else:
|
|
1140
|
+
subprocess.Popen(["xdg-open", url])
|
|
1141
|
+
except Exception:
|
|
1142
|
+
pass
|