ltcai 8.9.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
package/setup_wizard.py CHANGED
@@ -1,1308 +1,7 @@
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.
1
+ """Compatibility shim for :mod:`latticeai.setup.wizard`."""
5
2
 
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
3
  import sys
20
- import time
21
- from pathlib import Path
22
- from typing import Any, AsyncIterator, Dict, List, Tuple
23
-
24
- from latticeai.services.process_audit import (
25
- CommandConfirmationError,
26
- append_process_audit_event,
27
- command_plan,
28
- command_plan_for_commands,
29
- require_command_confirmation,
30
- )
31
-
32
- # ── Helpers ───────────────────────────────────────────────────────────────────
33
-
34
- def _cmd(args: List[str], timeout: int = 10) -> str:
35
- try:
36
- r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
37
- return (r.stdout or r.stderr or "").strip()
38
- except Exception:
39
- return ""
40
-
41
- def _sse(data: Dict) -> str:
42
- return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
43
-
44
-
45
- def _action_commands(action: Dict[str, Any]) -> List[List[str]]:
46
- atype = action.get("type")
47
- if atype == "pip":
48
- return [[sys.executable, "-m", "pip", "install", "--upgrade", str(pkg)] for pkg in action.get("packages", [])]
49
- if atype == "brew":
50
- package = str(action.get("package") or "")
51
- return [["brew", "install", package]] if package else []
52
- return []
53
-
54
-
55
- def _action_command_plan(action: Dict[str, Any], *, name: str) -> Dict[str, Any] | None:
56
- commands = _action_commands(action)
57
- if not commands:
58
- return None
59
- return command_plan_for_commands(
60
- commands,
61
- name=name,
62
- purpose="setup_wizard_install",
63
- metadata={"action_type": action.get("type")},
64
- )
65
-
66
-
67
- def _attach_action_plan(action: Dict[str, Any] | None, *, name: str) -> Dict[str, Any] | None:
68
- if not isinstance(action, dict):
69
- return action
70
- plan = _action_command_plan(action, name=name)
71
- if not plan:
72
- return action
73
- hydrated = dict(action)
74
- hydrated["command_plan"] = plan
75
- hydrated["confirmation_token"] = plan["confirmation_token"]
76
- return hydrated
77
-
78
-
79
- def _hydrate_install_actions(groups: Dict[str, Any]) -> Dict[str, Any]:
80
- for group_name in ("components", "engines", "models", "mcps"):
81
- items = groups.get(group_name)
82
- if not isinstance(items, list):
83
- continue
84
- for item in items:
85
- if isinstance(item, dict):
86
- item["action"] = _attach_action_plan(
87
- item.get("action"),
88
- name=str(item.get("id") or item.get("name") or group_name),
89
- )
90
- return groups
91
-
92
-
93
- def _verify_action_confirmation(action: Dict[str, Any], token: str | None, *, name: str) -> bool:
94
- plan = _action_command_plan(action, name=name)
95
- if not plan:
96
- return True
97
- return str(token or "").strip() == plan["confirmation_token"]
98
-
99
- OFFICIAL_DOWNLOADS: Dict[str, str] = {
100
- "homebrew": "https://brew.sh",
101
- "python": "https://www.python.org/downloads/",
102
- "node": "https://nodejs.org/en/download",
103
- "git": "https://git-scm.com/downloads",
104
- "ollama": "https://ollama.com/download",
105
- "lmstudio": "https://lmstudio.ai/download",
106
- "cuda": "https://developer.nvidia.com/cuda-downloads",
107
- "mlx": "https://ml-explore.github.io/mlx/build/html/install.html",
108
- "cloudflared": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
109
- "tesseract": "https://tesseract-ocr.github.io/tessdoc/Installation.html",
110
- }
111
-
112
- COMMON_PATH_DIRS = [
113
- "/opt/homebrew/bin",
114
- "/usr/local/bin",
115
- "/usr/bin",
116
- "/bin",
117
- str(Path.home() / ".local" / "bin"),
118
- str(Path.home() / ".cargo" / "bin"),
119
- str(Path.home() / ".latticeai" / "bin"),
120
- ]
121
-
122
- if platform.system() == "Windows":
123
- _local_appdata = os.environ.get("LOCALAPPDATA", "")
124
- _program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
125
- _program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
126
- COMMON_PATH_DIRS.extend([
127
- str(Path(_local_appdata) / "Programs" / "Ollama") if _local_appdata else "",
128
- str(Path(_program_files) / "Ollama"),
129
- str(Path(_program_files) / "LM Studio"),
130
- str(Path(_program_files) / "NVIDIA Corporation" / "NVSMI"),
131
- str(Path(_program_files_x86) / "NVIDIA Corporation" / "NVSMI"),
132
- ])
133
- COMMON_PATH_DIRS = [p for p in COMMON_PATH_DIRS if p]
134
-
135
- WINDOWS_BINARY_CANDIDATES: Dict[str, List[str]] = {
136
- "ollama": [
137
- str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Ollama" / "ollama.exe"),
138
- str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Ollama" / "ollama.exe"),
139
- ],
140
- "lms": [
141
- str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
142
- str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
143
- ],
144
- "nvidia-smi": [
145
- str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
146
- str(Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
147
- ],
148
- }
149
-
150
- PACKAGE_MODULES: Dict[str, str] = {
151
- "mlx-vlm": "mlx_vlm",
152
- "huggingface_hub[cli]": "huggingface_hub",
153
- "openai-whisper": "whisper",
154
- }
155
-
156
-
157
- def _project_env_file() -> Path:
158
- return Path(__file__).resolve().parent / ".env"
159
-
160
-
161
- def _update_env_file(env_file: Path, key: str, value: str) -> None:
162
- lines: List[str] = []
163
- found = False
164
- if env_file.exists():
165
- lines = env_file.read_text(encoding="utf-8").splitlines()
166
- updated: List[str] = []
167
- for line in lines:
168
- if line.startswith(f"{key}="):
169
- updated.append(f"{key}={value}")
170
- found = True
171
- else:
172
- updated.append(line)
173
- if not found:
174
- updated.append(f"{key}={value}")
175
- env_file.write_text("\n".join(updated) + "\n", encoding="utf-8")
176
-
177
-
178
- def _merge_path_dirs(dirs: List[str]) -> List[str]:
179
- current = os.environ.get("PATH", "")
180
- parts = [p for p in current.split(os.pathsep) if p]
181
- for item in dirs:
182
- expanded = str(Path(item).expanduser())
183
- if Path(expanded).exists() and expanded not in parts:
184
- parts.insert(0, expanded)
185
- os.environ["PATH"] = os.pathsep.join(parts)
186
- return parts
187
-
188
-
189
- def _persist_extra_path(dirs: List[str]) -> None:
190
- existing = [
191
- p for p in os.environ.get("LATTICEAI_EXTRA_PATH", "").split(os.pathsep)
192
- if p
193
- ]
194
- merged = existing[:]
195
- for item in dirs:
196
- expanded = str(Path(item).expanduser())
197
- if Path(expanded).exists() and expanded not in merged:
198
- merged.append(expanded)
199
- if merged:
200
- os.environ["LATTICEAI_EXTRA_PATH"] = os.pathsep.join(merged)
201
- _update_env_file(_project_env_file(), "LATTICEAI_EXTRA_PATH", os.environ["LATTICEAI_EXTRA_PATH"])
202
-
203
-
204
- def repair_path_for(binary: str | None = None) -> List[str]:
205
- before = _which_any(binary) if binary else None
206
- paths = _merge_path_dirs(COMMON_PATH_DIRS)
207
- if binary and not before and _which_any(binary):
208
- _persist_extra_path(COMMON_PATH_DIRS)
209
- return paths
210
-
211
-
212
- def _which_any(binary: str) -> str | None:
213
- path = shutil.which(binary)
214
- if path:
215
- return path
216
- if platform.system() == "Windows":
217
- for candidate in WINDOWS_BINARY_CANDIDATES.get(binary, []):
218
- if candidate and Path(candidate).exists():
219
- return candidate
220
- return None
221
-
222
-
223
- def _which_detail(binary: str) -> Dict[str, Any]:
224
- path = _which_any(binary)
225
- return {"installed": path is not None, "path": path}
226
-
227
-
228
- def _module_available(module_name: str) -> bool:
229
- import importlib.util
230
- return importlib.util.find_spec(module_name) is not None
231
-
232
-
233
- def _package_module(package: str) -> str:
234
- return PACKAGE_MODULES.get(package, package.replace("-", "_").split("[", 1)[0])
235
-
236
-
237
- def _component_detail(name: str, binary: str | None = None, module: str | None = None) -> Dict[str, Any]:
238
- detail: Dict[str, Any] = {"official_url": OFFICIAL_DOWNLOADS.get(name)}
239
- if binary:
240
- detail.update(_which_detail(binary))
241
- if module:
242
- detail["module_available"] = _module_available(module)
243
- detail["installed"] = bool(detail.get("installed") or detail["module_available"])
244
- return detail
245
-
246
-
247
- def _verify_binary(binary: str, version_args: List[str] | None = None, timeout: int = 20) -> Tuple[bool, str]:
248
- repair_path_for(binary)
249
- found = _which_any(binary)
250
- if not found:
251
- return False, f"{binary} 실행 파일을 PATH에서 찾지 못했습니다."
252
- args = [found, *(version_args or ["--version"])]
253
- try:
254
- completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
255
- except Exception as e:
256
- return False, str(e)
257
- output = (completed.stdout or completed.stderr or "").strip().splitlines()
258
- if completed.returncode == 0:
259
- return True, output[0] if output else found
260
- return False, (completed.stderr or completed.stdout or f"returncode={completed.returncode}")[-400:]
261
-
262
-
263
- async def _wait_for_binary(binary: str, seconds: int = 300) -> Tuple[bool, str]:
264
- deadline = time.time() + seconds
265
- while time.time() < deadline:
266
- ok, msg = _verify_binary(binary)
267
- if ok:
268
- return True, msg
269
- await asyncio.sleep(2)
270
- return False, f"{binary} 설치 완료를 제한 시간 안에 감지하지 못했습니다."
271
-
272
- # ── Environment Detection ─────────────────────────────────────────────────────
273
-
274
- def _detect_chip() -> Dict[str, Any]:
275
- arch = platform.machine()
276
- is_apple = arch == "arm64" and platform.system() == "Darwin"
277
- name = "Unknown CPU"
278
- gen: Any = None
279
-
280
- if is_apple:
281
- profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
282
- m = re.search(r"Chip:\s+(Apple M\S+)", profiler)
283
- name = m.group(1) if m else "Apple Silicon"
284
- gm = re.search(r"M(\d+)", name)
285
- gen = int(gm.group(1)) if gm else 1
286
- else:
287
- brand = ""
288
- if platform.system() == "Darwin":
289
- brand = _cmd(["sysctl", "-n", "machdep.cpu.brand_string"])
290
- elif platform.system() == "Windows":
291
- raw = _cmd(["wmic", "cpu", "get", "Name", "/value"], timeout=5)
292
- if "Name=" in raw:
293
- brand = raw.split("Name=", 1)[-1].splitlines()[0].strip()
294
- elif platform.system() == "Linux":
295
- try:
296
- for line in Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace").splitlines():
297
- if line.lower().startswith("model name"):
298
- brand = line.split(":", 1)[-1].strip()
299
- break
300
- except Exception:
301
- pass
302
- name = brand or platform.processor() or "Unknown CPU"
303
-
304
- return {"name": name, "arch": arch, "is_apple_silicon": is_apple, "gen": gen}
305
-
306
-
307
- def _detect_cpu() -> Dict[str, Any]:
308
- flags: List[str] = []
309
- physical_cores = os.cpu_count() or 0
310
- logical_cores = os.cpu_count() or 0
311
- model = _detect_chip()["name"]
312
- if platform.system() == "Darwin":
313
- flags = [item.lower() for item in _cmd(["sysctl", "-n", "machdep.cpu.features"], timeout=5).split()]
314
- try:
315
- physical_cores = int(_cmd(["sysctl", "-n", "hw.physicalcpu"], timeout=5) or physical_cores)
316
- logical_cores = int(_cmd(["sysctl", "-n", "hw.logicalcpu"], timeout=5) or logical_cores)
317
- except ValueError:
318
- pass
319
- elif platform.system() == "Linux":
320
- try:
321
- text = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace")
322
- for line in text.splitlines():
323
- if line.lower().startswith(("flags", "features")):
324
- flags = line.split(":", 1)[-1].strip().lower().split()
325
- break
326
- except Exception:
327
- pass
328
- elif platform.system() == "Windows":
329
- raw = _cmd(["wmic", "cpu", "get", "Name,NumberOfCores,NumberOfLogicalProcessors", "/format:list"], timeout=5)
330
- for line in raw.splitlines():
331
- key, _, value = line.partition("=")
332
- if key == "Name" and value.strip():
333
- model = value.strip()
334
- elif key == "NumberOfCores" and value.strip():
335
- try:
336
- physical_cores = int(value.strip())
337
- except ValueError:
338
- pass
339
- elif key == "NumberOfLogicalProcessors" and value.strip():
340
- try:
341
- logical_cores = int(value.strip())
342
- except ValueError:
343
- pass
344
- try:
345
- import ctypes
346
- kernel32 = ctypes.windll.kernel32
347
- feature_map = {
348
- 6: "sse",
349
- 10: "sse2",
350
- 13: "sse3",
351
- 19: "neon",
352
- 28: "rdrand",
353
- }
354
- flags.extend(name for code, name in feature_map.items() if kernel32.IsProcessorFeaturePresent(code))
355
- except Exception:
356
- pass
357
- interesting = {"avx", "avx2", "avx512f", "fma", "neon", "sse4_2"}
358
- if platform.system() == "Windows":
359
- interesting.update({"sse", "sse2", "sse3", "rdrand"})
360
- return {
361
- "model": model,
362
- "physical_cores": physical_cores,
363
- "logical_cores": logical_cores,
364
- "instructions": sorted({flag for flag in flags if flag in interesting}),
365
- }
366
-
367
- def _detect_ram_gb() -> float:
368
- if platform.system() == "Windows":
369
- raw = _cmd(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory", "/format:list"], timeout=5)
370
- for line in raw.splitlines():
371
- if line.startswith("TotalPhysicalMemory="):
372
- try:
373
- return round(int(line.split("=", 1)[-1].strip()) / 1_073_741_824, 1)
374
- except ValueError:
375
- break
376
- raw = _cmd(["sysctl", "-n", "hw.memsize"])
377
- if raw:
378
- try:
379
- return round(int(raw) / 1_073_741_824, 1)
380
- except ValueError:
381
- pass
382
- if platform.system() == "Darwin":
383
- profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
384
- m = re.search(r"Memory:\s+([\d.]+)\s*(TB|GB|MB)", profiler, re.IGNORECASE)
385
- if m:
386
- value = float(m.group(1))
387
- unit = m.group(2).lower()
388
- if unit == "tb":
389
- return round(value * 1024, 1)
390
- if unit == "gb":
391
- return round(value, 1)
392
- return round(value / 1024, 1)
393
- hostinfo = _cmd(["hostinfo"], timeout=5)
394
- m = re.search(r"Primary memory available:\s+([\d.]+)\s+gigabytes", hostinfo, re.IGNORECASE)
395
- if m:
396
- return round(float(m.group(1)), 1)
397
- try:
398
- with open("/proc/meminfo") as f:
399
- for line in f:
400
- if line.startswith("MemTotal:"):
401
- return round(int(line.split()[1]) / 1_048_576, 1)
402
- except Exception:
403
- pass
404
- return 0.0
405
-
406
- def _detect_disk_free_gb() -> float:
407
- try:
408
- path = "C:\\" if platform.system() == "Windows" else "/"
409
- return round(shutil.disk_usage(path).free / 1_073_741_824, 1)
410
- except Exception:
411
- return 0.0
412
-
413
-
414
- def _parse_windows_video_controllers(raw: str) -> List[Dict[str, Any]]:
415
- controllers: List[Dict[str, Any]] = []
416
- if not raw:
417
- return controllers
418
- try:
419
- data = _json.loads(raw)
420
- if isinstance(data, dict):
421
- data = [data]
422
- if isinstance(data, list):
423
- for item in data:
424
- name = str(item.get("Name") or "").strip()
425
- try:
426
- ram_mb = int(item.get("AdapterRAM") or 0) // (1024 * 1024)
427
- except Exception:
428
- ram_mb = 0
429
- if name:
430
- controllers.append({"name": name, "vram_mb": ram_mb})
431
- if controllers:
432
- return controllers
433
- except Exception:
434
- pass
435
- current: Dict[str, Any] = {}
436
- for line in raw.splitlines():
437
- if line.startswith("Name="):
438
- if current:
439
- controllers.append(current)
440
- current = {"name": line.split("=", 1)[-1].strip(), "vram_mb": 0}
441
- elif line.startswith("AdapterRAM=") and current:
442
- try:
443
- current["vram_mb"] = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
444
- except ValueError:
445
- current["vram_mb"] = 0
446
- if current:
447
- controllers.append(current)
448
- return controllers
449
-
450
-
451
- def _detect_gpu() -> Dict[str, Any]:
452
- devices: List[Dict[str, Any]] = []
453
- nvidia_smi = _which_any("nvidia-smi")
454
- if nvidia_smi:
455
- info = _cmd([nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], timeout=8)
456
- for line in [item.strip() for item in info.splitlines() if item.strip()]:
457
- try:
458
- name, mem = [part.strip() for part in line.split(",", 1)]
459
- devices.append({"vendor": "nvidia", "name": name, "vram_mb": int(float(mem)), "backend": "cuda"})
460
- except Exception:
461
- continue
462
-
463
- if platform.system() == "Windows":
464
- shell = _which_any("powershell") or _which_any("pwsh")
465
- raw = ""
466
- if shell:
467
- raw = _cmd([
468
- shell, "-NoProfile", "-Command",
469
- "Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM | ConvertTo-Json -Compress",
470
- ], timeout=8)
471
- if not raw:
472
- raw = _cmd(["wmic", "path", "win32_VideoController", "get", "Name,AdapterRAM", "/format:list"], timeout=8)
473
- for item in _parse_windows_video_controllers(raw):
474
- if any(existing.get("name") == item["name"] for existing in devices):
475
- continue
476
- low = item["name"].lower()
477
- vendor, backend = "unknown", "cpu"
478
- if "nvidia" in low or "geforce" in low or "rtx" in low:
479
- vendor, backend = "nvidia", "cuda"
480
- elif "amd" in low or "radeon" in low:
481
- vendor, backend = "amd", "directml/vulkan"
482
- elif "intel" in low or "arc" in low or "iris" in low:
483
- vendor, backend = "intel", "directml/vulkan"
484
- devices.append({"vendor": vendor, "name": item["name"], "vram_mb": item["vram_mb"], "backend": backend})
485
- elif platform.system() == "Darwin":
486
- sp = _cmd(["system_profiler", "SPDisplaysDataType"], timeout=8)
487
- for line in sp.splitlines():
488
- if "Chipset Model" in line:
489
- devices.append({"vendor": "apple", "name": line.split(":", 1)[-1].strip(), "vram_mb": 0, "backend": "metal/mlx"})
490
- break
491
- elif platform.system() == "Linux" and not devices:
492
- info = _cmd(["lspci"], timeout=5)
493
- for line in info.splitlines():
494
- low = line.lower()
495
- if not any(token in low for token in ("vga", "3d controller", "display")):
496
- continue
497
- if "nvidia" in low:
498
- devices.append({"vendor": "nvidia", "name": line.strip(), "vram_mb": 0, "backend": "cuda"})
499
- elif "amd" in low or "advanced micro devices" in low or "radeon" in low:
500
- devices.append({"vendor": "amd", "name": line.strip(), "vram_mb": 0, "backend": "rocm/vulkan"})
501
- elif "intel" in low:
502
- devices.append({"vendor": "intel", "name": line.strip(), "vram_mb": 0, "backend": "vulkan"})
503
-
504
- primary = max(devices, key=lambda item: int(item.get("vram_mb") or 0), default={})
505
- vram_mb = int(primary.get("vram_mb") or 0)
506
- return {
507
- "devices": devices,
508
- "vendor": primary.get("vendor", "none"),
509
- "name": primary.get("name", ""),
510
- "vram_mb": vram_mb,
511
- "vram_gb": round(vram_mb / 1024, 1),
512
- "backend": primary.get("backend", "cpu"),
513
- }
514
-
515
-
516
- def _detect_cuda() -> Dict[str, Any]:
517
- nvidia_smi = _which_any("nvidia-smi")
518
- nvcc = _which_any("nvcc")
519
- version = ""
520
- if nvidia_smi:
521
- raw = _cmd([nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], timeout=5)
522
- version = raw.splitlines()[0].strip() if raw.splitlines() else ""
523
- if nvcc:
524
- raw = _cmd([nvcc, "--version"], timeout=5)
525
- m = re.search(r"release\s+([\d.]+)", raw)
526
- if m:
527
- version = m.group(1)
528
- return {"available": bool(nvidia_smi or nvcc), "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
529
-
530
-
531
- def _detect_wsl() -> Dict[str, Any]:
532
- if platform.system() != "Linux":
533
- return {"is_wsl": False, "version": ""}
534
- raw = ""
535
- try:
536
- raw = Path("/proc/version").read_text(encoding="utf-8", errors="replace")
537
- except Exception:
538
- pass
539
- is_wsl = "microsoft" in raw.lower() or "wsl" in raw.lower()
540
- version = "2" if "microsoft-standard" in raw.lower() or "wsl2" in raw.lower() else ("1" if is_wsl else "")
541
- return {"is_wsl": is_wsl, "version": version}
542
-
543
-
544
- def _detect_tools() -> Dict[str, bool]:
545
- repair_path_for()
546
- return {t: _which_any(t) is not None
547
- for t in ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"]}
548
-
549
- def _detect_mlx() -> Dict[str, Any]:
550
- return {
551
- "available": _module_available("mlx"),
552
- "mlx_vlm": _module_available("mlx_vlm"),
553
- }
554
-
555
- def _detect_api_keys() -> Dict[str, bool]:
556
- return {
557
- "openai": bool(os.getenv("OPENAI_API_KEY")),
558
- "openrouter": bool(os.getenv("OPENROUTER_API_KEY")),
559
- "groq": bool(os.getenv("GROQ_API_KEY")),
560
- "together": bool(os.getenv("TOGETHER_API_KEY")),
561
- }
562
-
563
- def scan_environment() -> Dict[str, Any]:
564
- chip = _detect_chip()
565
- cpu = _detect_cpu()
566
- gpu = _detect_gpu()
567
- cuda = _detect_cuda()
568
- wsl = _detect_wsl()
569
- tools = _detect_tools()
570
- python_binary = "python3" if tools.get("python3") else "python"
571
- return {
572
- "os": platform.system(),
573
- "os_version": platform.mac_ver()[0] if platform.system() == "Darwin" else platform.version(),
574
- "chip": chip,
575
- "cpu": cpu,
576
- "gpu": gpu,
577
- "cuda": cuda,
578
- "wsl": wsl,
579
- "ram_gb": _detect_ram_gb(),
580
- "disk_free_gb": _detect_disk_free_gb(),
581
- "tools": tools,
582
- "components": {
583
- "homebrew": _component_detail("homebrew", "brew"),
584
- "python": {**_component_detail("python", python_binary), "version": platform.python_version()},
585
- "node": _component_detail("node", "node"),
586
- "npm": _component_detail("node", "npm"),
587
- "git": _component_detail("git", "git"),
588
- "ollama": _component_detail("ollama", "ollama"),
589
- "lmstudio": _component_detail("lmstudio", "lms"),
590
- "cuda": {**_component_detail("cuda", "nvcc"), **cuda},
591
- "tesseract": _component_detail("tesseract", "tesseract"),
592
- "mlx": _component_detail("mlx", module="mlx"),
593
- "mlx_vlm": _component_detail("mlx", module="mlx_vlm"),
594
- },
595
- "path": {
596
- "active": os.environ.get("PATH", ""),
597
- "extra": os.environ.get("LATTICEAI_EXTRA_PATH", ""),
598
- },
599
- "mlx": _detect_mlx(),
600
- "api_keys": _detect_api_keys(),
601
- }
602
-
603
- # ── Model Catalog ─────────────────────────────────────────────────────────────
604
- # (model_id, display_name, size_gb, tag, description, min_ram_gb)
605
- _MODEL_CATALOG = [
606
- ("mlx-community/Qwen3-VL-4B-Instruct-4bit", "Qwen3-VL 4B", 2.7, "VLM", "최신 Qwen 멀티모달 · 저사양", 4),
607
- ("mlx-community/gemma-4-e2b-it-4bit", "Gemma 4 E2B", 3.6, "VLM", "Gemma 4 소형 멀티모달", 8),
608
- ("mlx-community/Qwen3-VL-8B-Instruct-4bit", "Qwen3-VL 8B", 4.8, "VLM", "최신 Qwen 멀티모달 · 균형 추천", 16),
609
- ("mlx-community/gemma-4-12b-it-4bit", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
610
- ("mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit", "Llama 4 Scout", 11.8, "VLM", "Meta 최신 멀티모달 Scout", 24),
611
- ("mlx-community/gemma-4-26b-a4b-it-4bit", "Gemma 4 26B", 15.6, "VLM", "이미지 지원 · 대형 추천", 32),
612
- ("mlx-community/gemma-4-31b-it-4bit", "Gemma 4 31B", 18.4, "VLM+", "Gemma 4 최신 31B instruct", 48),
613
- ("mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "Qwen3-VL 30B A3B", 18.0, "VLM+", "최신 Qwen 대형 멀티모달", 48),
614
- ]
615
-
616
- _CROSS_PLATFORM_MODEL_CATALOG: Dict[str, List[Tuple[str, str, float, str, str, int]]] = {
617
- "ollama": [
618
- ("ollama:qwen3-vl:4b", "Qwen3-VL 4B", 2.7, "VLM", "Ollama 멀티모달 · 저사양", 4),
619
- ("ollama:qwen3-vl:8b", "Qwen3-VL 8B", 4.8, "VLM", "Ollama 멀티모달 · 균형 추천", 16),
620
- ("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),
621
- ("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),
622
- ("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),
623
- ],
624
- "lmstudio": [
625
- ("lmstudio:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "LM Studio 멀티모달 · 저사양", 4),
626
- ("lmstudio:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "LM Studio 멀티모달 · 균형 추천", 16),
627
- ("lmstudio:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B 4-bit", 7.9, "VLM", "LM Studio GGUF Gemma 4", 16),
628
- ("lmstudio:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B 4-bit", 18.7, "VLM+", "LM Studio GGUF Gemma 4", 48),
629
- ("lmstudio:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
630
- ("lmstudio:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
631
- ],
632
- "vllm": [
633
- ("vllm:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "내 컴퓨터 GPU 실행 도구 권장", 4),
634
- ("vllm:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "내 컴퓨터 NVIDIA 실행 도구 권장", 16),
635
- ("vllm:google/gemma-4-12b-it", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
636
- ("vllm:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
637
- ("vllm:suitch/gemma-4-31B-it-4bit", "Gemma 4 31B", 18.7, "VLM+", "Gemma 4 최신 31B instruct", 48),
638
- ("vllm:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
639
- ],
640
- "llamacpp": [
641
- ("llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF", "Qwen3-VL 4B GGUF", 2.7, "GGUF", "CPU/Vulkan 백업 · 멀티모달 GGUF", 4),
642
- ("llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF", "Qwen3-VL 8B GGUF", 4.8, "GGUF", "CPU/Vulkan 백업 · 균형형", 16),
643
- ("llamacpp:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B GGUF", 7.9, "GGUF", "Gemma 4 12B Q4_K_M", 16),
644
- ("llamacpp:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B GGUF", 18.7, "GGUF", "Gemma 4 31B Q4_K_M", 48),
645
- ("llamacpp:ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF", "Llama 4 Scout GGUF", 12.0, "GGUF", "Meta 최신 멀티모달 Scout", 24),
646
- ],
647
- }
648
-
649
- _VERSIONED_MODEL_PATTERNS = (
650
- ("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
651
- ("qwen", re.compile(r"\bqwen[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
652
- ("llama", re.compile(r"\bllama[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
653
- )
654
-
655
- _BEST_MODEL_TIERS: Dict[str, List[Tuple[int, str]]] = {
656
- "local_mlx": [
657
- (48, "mlx-community/gemma-4-31b-it-4bit"),
658
- (32, "mlx-community/gemma-4-26b-a4b-it-4bit"),
659
- (16, "mlx-community/gemma-4-12b-it-4bit"),
660
- (16, "mlx-community/Qwen3-VL-8B-Instruct-4bit"),
661
- (4, "mlx-community/Qwen3-VL-4B-Instruct-4bit"),
662
- ],
663
- "ollama": [
664
- (48, "ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M"),
665
- (16, "ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M"),
666
- (16, "ollama:qwen3-vl:8b"),
667
- (4, "ollama:qwen3-vl:4b"),
668
- ],
669
- "lmstudio": [
670
- (48, "lmstudio:ggml-org/gemma-4-31B-it-GGUF"),
671
- (16, "lmstudio:ggml-org/gemma-4-12B-it-GGUF"),
672
- (16, "lmstudio:Qwen/Qwen3-VL-8B-Instruct"),
673
- (4, "lmstudio:Qwen/Qwen3-VL-4B-Instruct"),
674
- ],
675
- "vllm": [
676
- (48, "vllm:suitch/gemma-4-31B-it-4bit"),
677
- (16, "vllm:google/gemma-4-12b-it"),
678
- (16, "vllm:Qwen/Qwen3-VL-8B-Instruct"),
679
- (4, "vllm:Qwen/Qwen3-VL-4B-Instruct"),
680
- ],
681
- "llamacpp": [
682
- (48, "llamacpp:ggml-org/gemma-4-31B-it-GGUF"),
683
- (16, "llamacpp:ggml-org/gemma-4-12B-it-GGUF"),
684
- (16, "llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF"),
685
- (4, "llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF"),
686
- ],
687
- }
688
-
689
-
690
- def _version_tuple(raw: str) -> Tuple[int, ...]:
691
- return tuple(int(part) for part in raw.split(".") if part.isdigit())
692
-
693
-
694
- def _catalog_row_family_version(row: Tuple[str, str, float, str, str, int]) -> Tuple[str, Tuple[int, ...]] | None:
695
- text = f"{row[0]} {row[1]}"
696
- for family, pattern in _VERSIONED_MODEL_PATTERNS:
697
- match = pattern.search(text)
698
- if match:
699
- version = _version_tuple(match.group(1))
700
- if version:
701
- return family, version
702
- return None
703
-
704
-
705
- def _filter_lower_family_versions(
706
- rows: List[Tuple[str, str, float, str, str, int]],
707
- ) -> List[Tuple[str, str, float, str, str, int]]:
708
- max_versions: Dict[str, Tuple[int, ...]] = {}
709
- detected: List[Tuple[Tuple[str, str, float, str, str, int], Tuple[str, Tuple[int, ...]] | None]] = []
710
- for row in rows:
711
- version_info = _catalog_row_family_version(row)
712
- detected.append((row, version_info))
713
- if not version_info:
714
- continue
715
- family, version = version_info
716
- if version > max_versions.get(family, (0,)):
717
- max_versions[family] = version
718
- return [
719
- row for row, version_info in detected
720
- if not version_info or version_info[1] >= max_versions.get(version_info[0], version_info[1])
721
- ]
722
-
723
-
724
- def _best_model_for_engine(engine: str, ram_gb: float, rows: List[Tuple[str, str, float, str, str, int]]) -> str:
725
- available_ids = {row[0] for row in rows}
726
- for min_ram, model_id in _BEST_MODEL_TIERS.get(engine, []):
727
- if ram_gb >= min_ram and model_id in available_ids:
728
- return model_id
729
- return rows[0][0] if rows else ""
730
-
731
-
732
- # ── Recommendation Logic ──────────────────────────────────────────────────────
733
-
734
- def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
735
- ram = env["ram_gb"]
736
- chip = env["chip"]
737
- mlx = env["mlx"]
738
- tools = env["tools"]
739
- api_keys = env["api_keys"]
740
- disk_free = env["disk_free_gb"]
741
- is_apple = chip["is_apple_silicon"]
742
- gpu = env.get("gpu", {})
743
- cuda = env.get("cuda", {})
744
- wsl = env.get("wsl", {})
745
- cpu = env.get("cpu", {})
746
- os_name = env.get("os", "")
747
-
748
- max_model_gb = ram * 0.72 # ~28% headroom for OS + apps
749
-
750
- if is_apple:
751
- preferred_engine = "local_mlx"
752
- elif gpu.get("vendor") == "nvidia" and cuda.get("available") and (os_name == "Linux" or wsl.get("is_wsl")):
753
- preferred_engine = "vllm"
754
- elif tools.get("lms"):
755
- preferred_engine = "lmstudio"
756
- elif tools.get("ollama"):
757
- preferred_engine = "ollama"
758
- else:
759
- preferred_engine = "llamacpp"
760
-
761
- apple_catalog = _filter_lower_family_versions(_MODEL_CATALOG)
762
- engine_catalog = (
763
- []
764
- if is_apple
765
- else _filter_lower_family_versions(_CROSS_PLATFORM_MODEL_CATALOG[preferred_engine])
766
- )
767
- best_id = _best_model_for_engine(
768
- "local_mlx" if is_apple else preferred_engine,
769
- ram,
770
- apple_catalog if is_apple else engine_catalog,
771
- )
772
-
773
- # ── Engines ──────────────────────────────────────────────────────────────
774
- engines: List[Dict] = []
775
-
776
- if is_apple:
777
- if mlx["available"] and mlx["mlx_vlm"]:
778
- engines.append({
779
- "id": "engine_mlx", "name": "MLX",
780
- "subtitle": f"{chip['name']} GPU 가속 · MLX-VLM 멀티모달 실행",
781
- "status": "installed", "priority": "recommended",
782
- "checked": True, "action": None, "badge": "설치됨",
783
- })
784
- else:
785
- engines.append({
786
- "id": "engine_mlx", "name": "MLX",
787
- "subtitle": f"{chip['name']} 전용 MLX-VLM 멀티모달 실행",
788
- "status": "available", "priority": "recommended",
789
- "checked": True,
790
- "action": {"type": "pip", "packages": ["mlx-vlm"], "verify_modules": ["mlx", "mlx_vlm"]},
791
- "badge": "설치 필요",
792
- })
793
-
794
- if tools.get("ollama"):
795
- engines.append({
796
- "id": "engine_ollama", "name": "Ollama",
797
- "subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
798
- "status": "installed", "priority": "recommended" if preferred_engine == "ollama" else "optional",
799
- "checked": preferred_engine == "ollama", "action": None, "badge": "설치됨",
800
- })
801
- else:
802
- hint = "brew install 가능" if (tools.get("brew") or env["os"] == "Darwin") else "수동 설치 필요"
803
- engines.append({
804
- "id": "engine_ollama", "name": "Ollama",
805
- "subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
806
- "status": "available", "priority": "recommended" if preferred_engine == "ollama" else "optional",
807
- "checked": preferred_engine == "ollama",
808
- "action": (
809
- {"type": "brew", "package": "ollama", "binary": "ollama", "official_url": OFFICIAL_DOWNLOADS["ollama"]}
810
- if tools.get("brew")
811
- else {"type": "url", "url": OFFICIAL_DOWNLOADS["ollama"], "binary": "ollama"}
812
- ),
813
- "badge": hint,
814
- })
815
-
816
- if not is_apple:
817
- lmstudio_installed = bool(tools.get("lms"))
818
- engines.append({
819
- "id": "engine_lmstudio", "name": "LM Studio",
820
- "subtitle": "Windows/macOS/Linux 데스크톱 GPU 서버 · 모델 다운로드 UI 포함",
821
- "status": "installed" if lmstudio_installed else "available",
822
- "priority": "recommended" if preferred_engine == "lmstudio" else "optional",
823
- "checked": preferred_engine == "lmstudio",
824
- "action": None if lmstudio_installed else {"type": "url", "url": OFFICIAL_DOWNLOADS["lmstudio"], "binary": "lms"},
825
- "badge": "설치됨" if lmstudio_installed else "설치 필요",
826
- })
827
- if gpu.get("vendor") == "nvidia":
828
- engines.append({
829
- "id": "engine_cuda", "name": "CUDA",
830
- "subtitle": f"NVIDIA {gpu.get('name') or 'GPU'} · VRAM {gpu.get('vram_gb') or 0} GB",
831
- "status": "installed" if cuda.get("available") else "available",
832
- "priority": "recommended",
833
- "checked": False,
834
- "action": None if cuda.get("available") else {"type": "url", "url": OFFICIAL_DOWNLOADS["cuda"], "binary": "nvcc"},
835
- "badge": cuda.get("version") or ("감지됨" if cuda.get("available") else "설치 필요"),
836
- })
837
- engines.append({
838
- "id": "engine_vllm", "name": "vLLM",
839
- "subtitle": "NVIDIA 서버형 추론 · Windows는 WSL/Linux 권장",
840
- "status": "available",
841
- "priority": "recommended" if preferred_engine == "vllm" else "optional",
842
- "checked": preferred_engine == "vllm",
843
- "action": {"type": "pip", "packages": ["vllm", "huggingface_hub[cli]"], "verify_modules": ["vllm", "huggingface_hub"]},
844
- "badge": "WSL/Linux 권장" if os_name == "Windows" and not wsl.get("is_wsl") else "설치 가능",
845
- })
846
- elif gpu.get("vendor") in {"amd", "intel"}:
847
- engines.append({
848
- "id": "engine_vulkan_directml", "name": "Vulkan/DirectML",
849
- "subtitle": f"{gpu.get('vendor', '').upper()} GPU 감지 · LM Studio 또는 llama.cpp 백엔드 권장",
850
- "status": "available",
851
- "priority": "recommended" if preferred_engine in {"lmstudio", "llamacpp"} else "optional",
852
- "checked": False,
853
- "action": None,
854
- "badge": gpu.get("backend") or "GPU",
855
- })
856
-
857
- components: List[Dict] = []
858
- component_specs = [
859
- ("homebrew", "Homebrew", "macOS 패키지 관리자 · 자동 설치 기반", "brew", None, "recommended"),
860
- ("git", "Git", "저장소 · 확장 · MCP 도구 연동에 필요", "git", "git", "recommended"),
861
- ("node", "Node.js", "npm 패키지와 VS Code 확장 개발에 필요", "node", "node", "optional"),
862
- ("tesseract", "Tesseract OCR", "이미지/PDF OCR 기능에 필요", "tesseract", "tesseract", "optional"),
863
- ]
864
- for cid, name, subtitle, binary, brew_pkg, priority in component_specs:
865
- installed = bool(tools.get(binary))
866
- if cid == "homebrew" and env["os"] != "Darwin":
867
- continue
868
- if installed:
869
- components.append({
870
- "id": f"component_{cid}", "name": name,
871
- "subtitle": subtitle, "status": "installed",
872
- "priority": priority, "checked": False, "action": None,
873
- "badge": "설치됨",
874
- })
875
- continue
876
- if cid == "homebrew":
877
- action = {"type": "url", "url": OFFICIAL_DOWNLOADS["homebrew"], "binary": "brew"}
878
- elif tools.get("brew") and brew_pkg:
879
- action = {"type": "brew", "package": brew_pkg, "binary": binary, "official_url": OFFICIAL_DOWNLOADS.get(cid)}
880
- else:
881
- action = {"type": "url", "url": OFFICIAL_DOWNLOADS.get(cid, ""), "binary": binary}
882
- components.append({
883
- "id": f"component_{cid}", "name": name,
884
- "subtitle": subtitle, "status": "available",
885
- "priority": priority, "checked": priority == "recommended",
886
- "action": action, "badge": "설치 필요",
887
- })
888
-
889
- python_ok = sys.version_info >= (3, 11)
890
- if not python_ok:
891
- components.insert(0, {
892
- "id": "component_python", "name": "Python 3.11+",
893
- "subtitle": "Lattice AI 서버 실행에 필요한 Python 런타임",
894
- "status": "available", "priority": "recommended", "checked": True,
895
- "action": {"type": "url", "url": OFFICIAL_DOWNLOADS["python"], "binary": "python3"},
896
- "badge": "업데이트 필요",
897
- })
898
-
899
- for provider, has_key in api_keys.items():
900
- if has_key:
901
- engines.append({
902
- "id": f"engine_{provider}", "name": provider.title(),
903
- "subtitle": f"{provider.upper()}_API_KEY 감지됨 · 클라우드 API",
904
- "status": "ready", "priority": "optional",
905
- "checked": False, "action": None, "badge": "준비됨",
906
- })
907
-
908
- # ── Models ───────────────────────────────────────────────────────────────
909
- models: List[Dict] = []
910
-
911
- if is_apple:
912
- for mid, mname, size_gb, tag, desc, min_ram in apple_catalog:
913
- fits = ram >= min_ram and size_gb <= max_model_gb and disk_free >= size_gb + 2
914
- is_best = mid == best_id
915
- models.append({
916
- "id": f"model_{mid.replace('/', '__').replace('-', '_')}",
917
- "model_id": mid,
918
- "name": mname,
919
- "subtitle": desc,
920
- "size_gb": size_gb,
921
- "tag": tag,
922
- "fits": fits,
923
- "priority": "recommended" if is_best else "optional",
924
- "checked": is_best and fits,
925
- "disabled": not fits,
926
- "badge": f"{size_gb} GB",
927
- "action": {"type": "load_model", "model_id": mid} if fits else None,
928
- })
929
- else:
930
- vram_gb = float(gpu.get("vram_gb") or 0)
931
- gpu_budget_gb = vram_gb * 1.15 if gpu.get("vendor") in {"nvidia", "amd", "intel"} and vram_gb else max_model_gb
932
- model_budget_gb = min(max_model_gb, gpu_budget_gb)
933
- for mid, mname, size_gb, tag, desc, min_ram in engine_catalog:
934
- fits = ram >= min_ram and size_gb <= model_budget_gb and disk_free >= size_gb + 2
935
- is_best = mid == best_id
936
- models.append({
937
- "id": f"model_{mid.replace('/', '__').replace(':', '__').replace('-', '_')}",
938
- "model_id": mid,
939
- "name": mname,
940
- "subtitle": desc,
941
- "size_gb": size_gb,
942
- "tag": tag,
943
- "fits": fits,
944
- "priority": "recommended" if is_best else "optional",
945
- "checked": is_best and fits,
946
- "disabled": not fits,
947
- "badge": f"{size_gb} GB · {preferred_engine}",
948
- "action": {"type": "load_model", "model_id": mid} if fits else None,
949
- })
950
- if models and not any(item.get("checked") for item in models):
951
- for item in models:
952
- if not item.get("disabled"):
953
- item["priority"] = "recommended"
954
- item["checked"] = True
955
- break
956
-
957
- # ── MCPs ─────────────────────────────────────────────────────────────────
958
- mcps: List[Dict] = [
959
- {
960
- "id": "mcp_files", "name": "Workspace Files",
961
- "subtitle": "파일 읽기/쓰기 · 코드 생성 · 미리보기",
962
- "status": "active", "priority": "recommended",
963
- "checked": True, "action": None,
964
- "badge": "기본 탑재", "needs_auth": False,
965
- },
966
- {
967
- "id": "mcp_presentations", "name": "Presentations",
968
- "subtitle": "PPTX · 슬라이드 자동 생성",
969
- "status": "active", "priority": "optional",
970
- "checked": False, "action": None,
971
- "badge": "기본 탑재", "needs_auth": False,
972
- },
973
- {
974
- "id": "mcp_github", "name": "GitHub",
975
- "subtitle": "저장소 · PR · 이슈 · CI 연동",
976
- "status": "available", "priority": "optional",
977
- "checked": False,
978
- "action": {"type": "auth", "url": "https://github.com/apps", "mcp_id": "github"},
979
- "badge": "인증 필요", "needs_auth": True,
980
- },
981
- {
982
- "id": "mcp_googledrive", "name": "Google Drive",
983
- "subtitle": "Docs · Sheets · Drive 파일 연동",
984
- "status": "available", "priority": "optional",
985
- "checked": False,
986
- "action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "google-drive"},
987
- "badge": "인증 필요", "needs_auth": True,
988
- },
989
- {
990
- "id": "mcp_slack", "name": "Slack",
991
- "subtitle": "팀 채널 공유 · 알림 워크플로",
992
- "status": "available", "priority": "optional",
993
- "checked": False,
994
- "action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "slack"},
995
- "badge": "인증 필요", "needs_auth": True,
996
- },
997
- ]
998
-
999
- return _hydrate_install_actions({
1000
- "components": components,
1001
- "engines": engines,
1002
- "models": models,
1003
- "mcps": mcps,
1004
- "summary": {
1005
- "chip": chip["name"],
1006
- "cpu_cores": cpu.get("logical_cores"),
1007
- "cpu_instructions": cpu.get("instructions", []),
1008
- "gpu": gpu.get("name") or gpu.get("vendor"),
1009
- "gpu_vendor": gpu.get("vendor"),
1010
- "vram_gb": gpu.get("vram_gb"),
1011
- "cuda": cuda.get("available"),
1012
- "cuda_version": cuda.get("version"),
1013
- "wsl": wsl,
1014
- "preferred_engine": preferred_engine,
1015
- "ram_gb": ram,
1016
- "disk_free_gb": disk_free,
1017
- "is_apple_silicon": is_apple,
1018
- "max_model_gb": round(max_model_gb, 1),
1019
- },
1020
- })
1021
-
1022
- # ── Installation Stream ───────────────────────────────────────────────────────
1023
-
1024
- def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
1025
- atype = action.get("type")
1026
- if atype == "pip":
1027
- modules = action.get("verify_modules") or [_package_module(pkg) for pkg in action.get("packages", [])]
1028
- missing = [module for module in modules if not _module_available(module)]
1029
- if missing:
1030
- return False, "Python 모듈 감지 실패: " + ", ".join(missing)
1031
- return True, "Python 모듈 import 테스트 통과"
1032
- binary = action.get("binary")
1033
- if binary:
1034
- return _verify_binary(binary)
1035
- return True, "검증 항목 없음"
1036
-
1037
-
1038
- async def _repair_action(
1039
- action: Dict[str, Any],
1040
- *,
1041
- confirmation_token: str | None = None,
1042
- actor: str | None = None,
1043
- ) -> Tuple[bool, str]:
1044
- binary = action.get("binary")
1045
- if binary:
1046
- repair_path_for(binary)
1047
- ok, msg = _verify_binary(binary)
1048
- if ok:
1049
- return True, f"PATH 자동 보정 완료: {msg}"
1050
- if action.get("type") == "pip":
1051
- packages = action.get("packages", [])
1052
- if packages:
1053
- if not _verify_action_confirmation(action, confirmation_token, name="repair_action"):
1054
- return False, "설치 명령 확인 토큰이 일치하지 않습니다."
1055
- for pkg in packages:
1056
- success, err = await _pip_install(pkg, confirmed=True, actor=actor)
1057
- if not success:
1058
- return False, err
1059
- return _verify_action(action)
1060
- return False, "자동 복구 방법을 찾지 못했습니다."
1061
-
1062
-
1063
- async def install_stream(
1064
- items: List[Dict],
1065
- router: Any,
1066
- *,
1067
- confirmation_token: str | None = None,
1068
- user_email: str | None = None,
1069
- ) -> AsyncIterator[str]:
1070
- for item in items:
1071
- item_id = item.get("id", "unknown")
1072
- name = item.get("name", item_id)
1073
- action = item.get("action") or {}
1074
- atype = action.get("type")
1075
-
1076
- if not atype:
1077
- yield _sse({"id": item_id, "status": "skipped", "msg": f"{name} — 이미 준비됨"})
1078
- await asyncio.sleep(0.04)
1079
- continue
1080
-
1081
- yield _sse({"id": item_id, "status": "starting", "msg": f"{name} 준비 중..."})
1082
-
1083
- if atype == "pip":
1084
- packages = action.get("packages", [])
1085
- token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
1086
- if not _verify_action_confirmation(action, token, name=str(item_id)):
1087
- yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
1088
- continue
1089
- ok = True
1090
- for pkg in packages:
1091
- yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
1092
- success, err = await _pip_install(pkg, confirmed=True, actor=user_email)
1093
- if success:
1094
- yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
1095
- else:
1096
- yield _sse({"id": item_id, "status": "error", "msg": f"{pkg} 실패: {err[:400]}"})
1097
- ok = False
1098
- break
1099
- if ok:
1100
- yield _sse({"id": item_id, "status": "running", "msg": f"{name} 동작 테스트 중..."})
1101
- verified, detail = _verify_action(action)
1102
- if verified:
1103
- yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
1104
- else:
1105
- yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1106
- repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
1107
- yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1108
-
1109
- elif atype == "brew":
1110
- pkg = action.get("package", "")
1111
- token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
1112
- if not _verify_action_confirmation(action, token, name=str(item_id)):
1113
- yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
1114
- continue
1115
- yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
1116
- success, err = await _brew_install(pkg, confirmed=True, actor=user_email)
1117
- if success:
1118
- yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
1119
- binary = action.get("binary")
1120
- if binary:
1121
- repair_path_for(binary)
1122
- verified, detail = _verify_action(action)
1123
- if verified:
1124
- yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
1125
- else:
1126
- yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1127
- repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
1128
- yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1129
- else:
1130
- url = action.get("official_url") or action.get("url")
1131
- if url:
1132
- yield _sse({"id": item_id, "status": "auth", "msg": f"자동 설치 실패 — 공식 다운로드 페이지를 엽니다.\n{err[:240]}", "auth_url": url})
1133
- open_url(url)
1134
- yield _sse({"id": item_id, "status": "error", "msg": f"실패: {err[:400]}"})
1135
-
1136
- elif atype == "load_model":
1137
- model_id = action.get("model_id", "")
1138
- yield _sse({"id": item_id, "status": "running",
1139
- "msg": f"모델 다운로드 · 로딩 중...\n{model_id}\n(용량에 따라 수 분 소요)"})
1140
- try:
1141
- msg = await router.load_model(model_id)
1142
- yield _sse({"id": item_id, "status": "done", "msg": f"{name} 로드 완료 ✅"})
1143
- except Exception as e:
1144
- yield _sse({"id": item_id, "status": "error", "msg": f"로드 실패: {str(e)[:400]}"})
1145
-
1146
- elif atype == "auth":
1147
- url = action.get("url", "")
1148
- yield _sse({"id": item_id, "status": "auth",
1149
- "msg": "브라우저에서 인증 페이지를 엽니다...", "auth_url": url})
1150
- open_url(url)
1151
- yield _sse({"id": item_id, "status": "waiting",
1152
- "msg": "브라우저에서 인증 완료 후 계속하세요"})
1153
-
1154
- elif atype == "url":
1155
- url = action.get("url", "")
1156
- yield _sse({"id": item_id, "status": "auth",
1157
- "msg": "설치 페이지를 브라우저에서 엽니다...", "auth_url": url})
1158
- open_url(url)
1159
- binary = action.get("binary")
1160
- if binary:
1161
- yield _sse({"id": item_id, "status": "waiting",
1162
- "msg": f"{binary} 설치 완료를 자동 감지하는 중입니다..."})
1163
- ok, detail = await _wait_for_binary(binary)
1164
- if ok:
1165
- repair_path_for(binary)
1166
- yield _sse({"id": item_id, "status": "done",
1167
- "msg": f"{name} 설치 · PATH 연결 · 검증 완료 ✅\n{detail}"})
1168
- else:
1169
- yield _sse({"id": item_id, "status": "error",
1170
- "msg": f"{detail}\n공식 페이지에서 설치 후 다시 시도하세요."})
1171
- else:
1172
- yield _sse({"id": item_id, "status": "waiting",
1173
- "msg": "브라우저에서 설치 또는 인증을 완료한 뒤 다시 시도하세요"})
1174
-
1175
- else:
1176
- yield _sse({"id": item_id, "status": "error", "msg": f"알 수 없는 액션: {atype}"})
1177
-
1178
- yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
1179
-
1180
-
1181
- async def _pip_install(
1182
- package: str,
1183
- *,
1184
- confirmation_token: str | None = None,
1185
- confirmed: bool = False,
1186
- actor: str | None = None,
1187
- ) -> Tuple[bool, str]:
1188
- command = [sys.executable, "-m", "pip", "install", "--upgrade", package]
1189
- plan = command_plan(
1190
- command,
1191
- name=f"pip:{package}",
1192
- purpose="setup_wizard_install",
1193
- metadata={"package": package},
1194
- )
1195
- try:
1196
- if not confirmed:
1197
- require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
1198
- append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
1199
- proc = await asyncio.create_subprocess_exec(
1200
- *command,
1201
- stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1202
- )
1203
- _, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
1204
- stderr_text = stderr.decode(errors="replace")
1205
- append_process_audit_event(
1206
- "setup_wizard_install",
1207
- plan=plan,
1208
- status="finished",
1209
- user_email=actor,
1210
- returncode=proc.returncode,
1211
- stderr=stderr_text,
1212
- )
1213
- if proc.returncode == 0:
1214
- return True, ""
1215
- return False, stderr_text
1216
- except CommandConfirmationError as e:
1217
- append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
1218
- return False, str(e)
1219
- except asyncio.TimeoutError:
1220
- append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
1221
- return False, "설치 시간 초과 (10분)"
1222
- except Exception as e:
1223
- append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
1224
- return False, str(e)
1225
-
1226
-
1227
- async def _brew_install(
1228
- package: str,
1229
- *,
1230
- confirmation_token: str | None = None,
1231
- confirmed: bool = False,
1232
- actor: str | None = None,
1233
- ) -> Tuple[bool, str]:
1234
- brew = shutil.which("brew")
1235
- if not brew:
1236
- return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
1237
- command = [brew, "install", package]
1238
- plan = command_plan(
1239
- command,
1240
- name=f"brew:{package}",
1241
- purpose="setup_wizard_install",
1242
- metadata={"package": package},
1243
- )
1244
- try:
1245
- if not confirmed:
1246
- require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
1247
- append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
1248
- proc = await asyncio.create_subprocess_exec(
1249
- *command,
1250
- stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1251
- )
1252
- _, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
1253
- stderr_text = stderr.decode(errors="replace")
1254
- append_process_audit_event(
1255
- "setup_wizard_install",
1256
- plan=plan,
1257
- status="finished",
1258
- user_email=actor,
1259
- returncode=proc.returncode,
1260
- stderr=stderr_text,
1261
- )
1262
- if proc.returncode == 0:
1263
- return True, ""
1264
- return False, stderr_text
1265
- except CommandConfirmationError as e:
1266
- append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
1267
- return False, str(e)
1268
- except asyncio.TimeoutError:
1269
- append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
1270
- return False, "설치 시간 초과 (5분)"
1271
- except Exception as e:
1272
- append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
1273
- return False, str(e)
1274
4
 
5
+ from latticeai.setup import wizard as _impl
1275
6
 
1276
- def open_url(url: str) -> None:
1277
- command: List[str]
1278
- try:
1279
- system = platform.system()
1280
- if system == "Darwin":
1281
- command = ["open", url]
1282
- plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1283
- append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1284
- subprocess.Popen(command)
1285
- append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1286
- elif system == "Windows":
1287
- command = ["start", "", url]
1288
- plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1289
- append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1290
- subprocess.Popen(command, shell=True)
1291
- append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1292
- else:
1293
- command = ["xdg-open", url]
1294
- plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1295
- append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1296
- subprocess.Popen(command)
1297
- append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1298
- except Exception as exc:
1299
- try:
1300
- append_process_audit_event(
1301
- "setup_wizard_open_url",
1302
- plan=command_plan(["open_url", url], name="open_url", purpose="setup_wizard_open_url"),
1303
- status="error",
1304
- error=str(exc),
1305
- )
1306
- except Exception:
1307
- pass
1308
- pass
7
+ sys.modules[__name__] = _impl