ltcai 8.8.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +368 -418
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +196 -74
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
|
@@ -107,6 +107,53 @@ class ToolDispatchService:
|
|
|
107
107
|
detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
|
|
108
108
|
)
|
|
109
109
|
|
|
110
|
+
def user_role(self, current_user: str) -> str:
|
|
111
|
+
users = self.load_users()
|
|
112
|
+
try:
|
|
113
|
+
return str(self.get_user_role(current_user, users) or "user")
|
|
114
|
+
except Exception:
|
|
115
|
+
return "user"
|
|
116
|
+
|
|
117
|
+
def enforce_policy(
|
|
118
|
+
self,
|
|
119
|
+
tool_name: str,
|
|
120
|
+
args: Optional[dict],
|
|
121
|
+
*,
|
|
122
|
+
current_user: str,
|
|
123
|
+
source: str,
|
|
124
|
+
require_auto_approval: bool = True,
|
|
125
|
+
trusted_admin: bool = False,
|
|
126
|
+
) -> ToolPolicy:
|
|
127
|
+
"""Authorize a tool call before any hook or handler can execute.
|
|
128
|
+
|
|
129
|
+
This is the shared policy gate for direct HTTP/MCP/tool surfaces. Agent
|
|
130
|
+
runtime uses the same policy data and blocks non-auto-approved steps in
|
|
131
|
+
its state machine so Computer Use direct endpoints can remain unchanged
|
|
132
|
+
when explicitly excluded from a hardening pass.
|
|
133
|
+
"""
|
|
134
|
+
policy = self.policy_for(tool_name, args or {})
|
|
135
|
+
if not trusted_admin:
|
|
136
|
+
self.check_role(tool_name, current_user)
|
|
137
|
+
if policy["destructive"] or policy["risk"] == "destructive":
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
status_code=403,
|
|
140
|
+
detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
|
|
141
|
+
)
|
|
142
|
+
if (
|
|
143
|
+
require_auto_approval
|
|
144
|
+
and not trusted_admin
|
|
145
|
+
and not policy["auto_approve"]
|
|
146
|
+
and self.user_role(current_user) != "admin"
|
|
147
|
+
):
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=403,
|
|
150
|
+
detail=(
|
|
151
|
+
f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
|
|
152
|
+
"9.0.0에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
return policy
|
|
156
|
+
|
|
110
157
|
def rollback_file(self, path: str) -> Dict[str, Any]:
|
|
111
158
|
r = subprocess.run(
|
|
112
159
|
["git", "checkout", "--", path],
|
|
@@ -160,6 +207,25 @@ def check_tool_role(tool_name: str, current_user: str) -> None:
|
|
|
160
207
|
DEFAULT_TOOL_DISPATCH_SERVICE.check_role(tool_name, current_user)
|
|
161
208
|
|
|
162
209
|
|
|
210
|
+
def enforce_tool_policy(
|
|
211
|
+
tool_name: str,
|
|
212
|
+
args: Optional[dict],
|
|
213
|
+
*,
|
|
214
|
+
current_user: str,
|
|
215
|
+
source: str,
|
|
216
|
+
require_auto_approval: bool = True,
|
|
217
|
+
trusted_admin: bool = False,
|
|
218
|
+
) -> ToolPolicy:
|
|
219
|
+
return DEFAULT_TOOL_DISPATCH_SERVICE.enforce_policy(
|
|
220
|
+
tool_name,
|
|
221
|
+
args or {},
|
|
222
|
+
current_user=current_user,
|
|
223
|
+
source=source,
|
|
224
|
+
require_auto_approval=require_auto_approval,
|
|
225
|
+
trusted_admin=trusted_admin,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
163
229
|
def collect_created_files(transcript: list) -> list:
|
|
164
230
|
files = []
|
|
165
231
|
for step in transcript:
|
|
@@ -79,6 +79,21 @@ class WorkspaceService:
|
|
|
79
79
|
def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
80
80
|
return self.store.has_permission(workspace_id, self._identity(user_id), "write")
|
|
81
81
|
|
|
82
|
+
def readable_workspaces(self, user_id: Optional[str]) -> list[str]:
|
|
83
|
+
"""Return workspace ids the caller can read.
|
|
84
|
+
|
|
85
|
+
This keeps scoped read APIs from each reconstructing membership logic
|
|
86
|
+
from raw workspace state. The personal workspace remains readable via
|
|
87
|
+
the store's normal permission rules.
|
|
88
|
+
"""
|
|
89
|
+
resolved_user = self._identity(user_id)
|
|
90
|
+
workspaces = (self.store.load_state().get("workspaces") or {})
|
|
91
|
+
return [
|
|
92
|
+
str(workspace_id)
|
|
93
|
+
for workspace_id in workspaces
|
|
94
|
+
if self.store.has_permission(str(workspace_id), resolved_user, "read")
|
|
95
|
+
]
|
|
96
|
+
|
|
82
97
|
# ── record-level authorization (by-id access must not bypass gating) ──
|
|
83
98
|
|
|
84
99
|
def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
|
package/package.json
CHANGED
|
@@ -4,10 +4,12 @@ import { join, relative } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const repo = join(import.meta.dirname, "..");
|
|
6
6
|
const roots = [
|
|
7
|
-
join(repo, "frontend", "src"
|
|
8
|
-
join(repo, "frontend", "src", "features", "admin"),
|
|
9
|
-
join(repo, "frontend", "src", "components", "onboarding"),
|
|
7
|
+
join(repo, "frontend", "src"),
|
|
10
8
|
];
|
|
9
|
+
const allowlistPath = join(repo, "scripts", "i18n_literal_allowlist.json");
|
|
10
|
+
const allowlist = existsSync(allowlistPath)
|
|
11
|
+
? JSON.parse(readFileSync(allowlistPath, "utf8"))
|
|
12
|
+
: {};
|
|
11
13
|
|
|
12
14
|
function walk(dir) {
|
|
13
15
|
if (!existsSync(dir)) return [];
|
|
@@ -16,7 +18,9 @@ function walk(dir) {
|
|
|
16
18
|
const path = join(dir, name);
|
|
17
19
|
const stat = statSync(path);
|
|
18
20
|
if (stat.isDirectory()) out.push(...walk(path));
|
|
19
|
-
else if (name.endsWith(".tsx")
|
|
21
|
+
else if ((name.endsWith(".tsx") || name.endsWith(".ts")) && name !== "openapi.ts") {
|
|
22
|
+
out.push(path);
|
|
23
|
+
}
|
|
20
24
|
}
|
|
21
25
|
return out;
|
|
22
26
|
}
|
|
@@ -25,6 +29,7 @@ const rawLocalizedProps = /\b(?:aria-label|placeholder|title)=["'][^"'{]*[A-Za-z
|
|
|
25
29
|
const rawJsxText = />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,})\s*</g;
|
|
26
30
|
const rawComponentCopy = /\b(?:title|detail|description|successLabel|empty)=["'][^"'{]*[A-Za-z][^"']*["']/g;
|
|
27
31
|
let failures = 0;
|
|
32
|
+
let allowed = 0;
|
|
28
33
|
|
|
29
34
|
for (const file of roots.flatMap(walk)) {
|
|
30
35
|
const text = readFileSync(file, "utf8");
|
|
@@ -38,9 +43,16 @@ for (const file of roots.flatMap(walk)) {
|
|
|
38
43
|
matches.push(`JSX text: ${literal}`);
|
|
39
44
|
}
|
|
40
45
|
if (!matches.length) continue;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
const rel = relative(repo, file);
|
|
47
|
+
const budget = Number(allowlist[rel]?.maxFindings || 0);
|
|
48
|
+
if (budget >= matches.length) {
|
|
49
|
+
allowed += matches.length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const newMatches = matches.slice(budget);
|
|
53
|
+
failures += newMatches.length;
|
|
54
|
+
for (const match of newMatches) {
|
|
55
|
+
console.error(`${rel}: hardcoded localized prop: ${match}`);
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -49,4 +61,4 @@ if (failures) {
|
|
|
49
61
|
process.exit(1);
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
console.log(
|
|
64
|
+
console.log(`i18n literal check: localized props use translation keys (${allowed} allowlisted legacy literal(s))`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"frontend/src/App.tsx": {
|
|
3
|
+
"maxFindings": 1,
|
|
4
|
+
"reason": "legacy shell aria label pending translation-key migration"
|
|
5
|
+
},
|
|
6
|
+
"frontend/src/components/LivingBrain.tsx": {
|
|
7
|
+
"maxFindings": 1,
|
|
8
|
+
"reason": "legacy visual component aria label pending translation-key migration"
|
|
9
|
+
},
|
|
10
|
+
"frontend/src/components/primitives.tsx": {
|
|
11
|
+
"maxFindings": 1,
|
|
12
|
+
"reason": "shared empty-state fallback pending translation-key migration"
|
|
13
|
+
},
|
|
14
|
+
"frontend/src/pages/Act.tsx": {
|
|
15
|
+
"maxFindings": 41,
|
|
16
|
+
"reason": "large legacy automation page pending incremental copy migration"
|
|
17
|
+
},
|
|
18
|
+
"frontend/src/pages/Brain.tsx": {
|
|
19
|
+
"maxFindings": 25,
|
|
20
|
+
"reason": "large legacy memory page pending incremental copy migration"
|
|
21
|
+
},
|
|
22
|
+
"frontend/src/pages/Capture.tsx": {
|
|
23
|
+
"maxFindings": 2,
|
|
24
|
+
"reason": "technical type-name literals are intentionally preserved for now"
|
|
25
|
+
},
|
|
26
|
+
"frontend/src/pages/Library.tsx": {
|
|
27
|
+
"maxFindings": 21,
|
|
28
|
+
"reason": "large legacy model/plugin page pending incremental copy migration"
|
|
29
|
+
},
|
|
30
|
+
"frontend/src/pages/System.tsx": {
|
|
31
|
+
"maxFindings": 118,
|
|
32
|
+
"reason": "large legacy system/admin page pending incremental copy migration"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -52,8 +52,12 @@ for (const file of files) {
|
|
|
52
52
|
console.log(`privacy: ${scanned} frontend/static files scanned`);
|
|
53
53
|
|
|
54
54
|
const client = readFileSync(join(frontend, "src", "api", "client.ts"), "utf8");
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const apiBase = readFileSync(join(frontend, "src", "api", "base.ts"), "utf8");
|
|
56
|
+
if (
|
|
57
|
+
!(client.includes("openapi-fetch") || apiBase.includes("openapi-fetch")) ||
|
|
58
|
+
!(client.includes("./openapi") || apiBase.includes("./openapi"))
|
|
59
|
+
) {
|
|
60
|
+
fail("frontend/src/api/client.ts or base.ts must use the generated OpenAPI client");
|
|
57
61
|
}
|
|
58
62
|
if (!existsSync(join(frontend, "src", "api", "openapi.ts"))) {
|
|
59
63
|
fail("generated OpenAPI types missing");
|
package/setup_wizard.py
CHANGED
|
@@ -21,6 +21,20 @@ import time
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any, AsyncIterator, Dict, List, Tuple
|
|
23
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
|
+
from latticeai.services.setup_detection import (
|
|
32
|
+
detect_cuda,
|
|
33
|
+
detect_tools,
|
|
34
|
+
detect_wsl_from_text,
|
|
35
|
+
parse_windows_video_controllers as _parse_windows_video_controllers,
|
|
36
|
+
)
|
|
37
|
+
|
|
24
38
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
25
39
|
|
|
26
40
|
def _cmd(args: List[str], timeout: int = 10) -> str:
|
|
@@ -33,6 +47,61 @@ def _cmd(args: List[str], timeout: int = 10) -> str:
|
|
|
33
47
|
def _sse(data: Dict) -> str:
|
|
34
48
|
return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
|
|
35
49
|
|
|
50
|
+
|
|
51
|
+
def _action_commands(action: Dict[str, Any]) -> List[List[str]]:
|
|
52
|
+
atype = action.get("type")
|
|
53
|
+
if atype == "pip":
|
|
54
|
+
return [[sys.executable, "-m", "pip", "install", "--upgrade", str(pkg)] for pkg in action.get("packages", [])]
|
|
55
|
+
if atype == "brew":
|
|
56
|
+
package = str(action.get("package") or "")
|
|
57
|
+
return [["brew", "install", package]] if package else []
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _action_command_plan(action: Dict[str, Any], *, name: str) -> Dict[str, Any] | None:
|
|
62
|
+
commands = _action_commands(action)
|
|
63
|
+
if not commands:
|
|
64
|
+
return None
|
|
65
|
+
return command_plan_for_commands(
|
|
66
|
+
commands,
|
|
67
|
+
name=name,
|
|
68
|
+
purpose="setup_wizard_install",
|
|
69
|
+
metadata={"action_type": action.get("type")},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _attach_action_plan(action: Dict[str, Any] | None, *, name: str) -> Dict[str, Any] | None:
|
|
74
|
+
if not isinstance(action, dict):
|
|
75
|
+
return action
|
|
76
|
+
plan = _action_command_plan(action, name=name)
|
|
77
|
+
if not plan:
|
|
78
|
+
return action
|
|
79
|
+
hydrated = dict(action)
|
|
80
|
+
hydrated["command_plan"] = plan
|
|
81
|
+
hydrated["confirmation_token"] = plan["confirmation_token"]
|
|
82
|
+
return hydrated
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _hydrate_install_actions(groups: Dict[str, Any]) -> Dict[str, Any]:
|
|
86
|
+
for group_name in ("components", "engines", "models", "mcps"):
|
|
87
|
+
items = groups.get(group_name)
|
|
88
|
+
if not isinstance(items, list):
|
|
89
|
+
continue
|
|
90
|
+
for item in items:
|
|
91
|
+
if isinstance(item, dict):
|
|
92
|
+
item["action"] = _attach_action_plan(
|
|
93
|
+
item.get("action"),
|
|
94
|
+
name=str(item.get("id") or item.get("name") or group_name),
|
|
95
|
+
)
|
|
96
|
+
return groups
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _verify_action_confirmation(action: Dict[str, Any], token: str | None, *, name: str) -> bool:
|
|
100
|
+
plan = _action_command_plan(action, name=name)
|
|
101
|
+
if not plan:
|
|
102
|
+
return True
|
|
103
|
+
return str(token or "").strip() == plan["confirmation_token"]
|
|
104
|
+
|
|
36
105
|
OFFICIAL_DOWNLOADS: Dict[str, str] = {
|
|
37
106
|
"homebrew": "https://brew.sh",
|
|
38
107
|
"python": "https://www.python.org/downloads/",
|
|
@@ -348,43 +417,6 @@ def _detect_disk_free_gb() -> float:
|
|
|
348
417
|
return 0.0
|
|
349
418
|
|
|
350
419
|
|
|
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
420
|
def _detect_gpu() -> Dict[str, Any]:
|
|
389
421
|
devices: List[Dict[str, Any]] = []
|
|
390
422
|
nvidia_smi = _which_any("nvidia-smi")
|
|
@@ -451,37 +483,24 @@ def _detect_gpu() -> Dict[str, Any]:
|
|
|
451
483
|
|
|
452
484
|
|
|
453
485
|
def _detect_cuda() -> Dict[str, Any]:
|
|
454
|
-
nvidia_smi = _which_any(
|
|
455
|
-
|
|
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}
|
|
486
|
+
available, version, nvidia_smi, nvcc = detect_cuda(_which_any, lambda args: _cmd(args, timeout=5))
|
|
487
|
+
return {"available": available, "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
|
|
466
488
|
|
|
467
489
|
|
|
468
490
|
def _detect_wsl() -> Dict[str, Any]:
|
|
469
|
-
if platform.system() != "Linux":
|
|
470
|
-
return {"is_wsl": False, "version": ""}
|
|
471
491
|
raw = ""
|
|
472
492
|
try:
|
|
473
493
|
raw = Path("/proc/version").read_text(encoding="utf-8", errors="replace")
|
|
474
494
|
except Exception:
|
|
475
495
|
pass
|
|
476
|
-
is_wsl =
|
|
477
|
-
version = "2" if "microsoft-standard" in raw.lower() or "wsl2" in raw.lower() else ("1" if is_wsl else "")
|
|
496
|
+
is_wsl, version = detect_wsl_from_text(platform.system().lower(), raw)
|
|
478
497
|
return {"is_wsl": is_wsl, "version": version}
|
|
479
498
|
|
|
480
499
|
|
|
481
500
|
def _detect_tools() -> Dict[str, bool]:
|
|
482
501
|
repair_path_for()
|
|
483
|
-
|
|
484
|
-
|
|
502
|
+
detected = detect_tools(_which_any, ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"])
|
|
503
|
+
return {tool: path is not None for tool, path in detected.items()}
|
|
485
504
|
|
|
486
505
|
def _detect_mlx() -> Dict[str, Any]:
|
|
487
506
|
return {
|
|
@@ -933,7 +952,7 @@ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
933
952
|
},
|
|
934
953
|
]
|
|
935
954
|
|
|
936
|
-
return {
|
|
955
|
+
return _hydrate_install_actions({
|
|
937
956
|
"components": components,
|
|
938
957
|
"engines": engines,
|
|
939
958
|
"models": models,
|
|
@@ -954,7 +973,7 @@ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
954
973
|
"is_apple_silicon": is_apple,
|
|
955
974
|
"max_model_gb": round(max_model_gb, 1),
|
|
956
975
|
},
|
|
957
|
-
}
|
|
976
|
+
})
|
|
958
977
|
|
|
959
978
|
# ── Installation Stream ───────────────────────────────────────────────────────
|
|
960
979
|
|
|
@@ -972,7 +991,12 @@ def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
|
|
|
972
991
|
return True, "검증 항목 없음"
|
|
973
992
|
|
|
974
993
|
|
|
975
|
-
async def _repair_action(
|
|
994
|
+
async def _repair_action(
|
|
995
|
+
action: Dict[str, Any],
|
|
996
|
+
*,
|
|
997
|
+
confirmation_token: str | None = None,
|
|
998
|
+
actor: str | None = None,
|
|
999
|
+
) -> Tuple[bool, str]:
|
|
976
1000
|
binary = action.get("binary")
|
|
977
1001
|
if binary:
|
|
978
1002
|
repair_path_for(binary)
|
|
@@ -982,15 +1006,23 @@ async def _repair_action(action: Dict[str, Any]) -> Tuple[bool, str]:
|
|
|
982
1006
|
if action.get("type") == "pip":
|
|
983
1007
|
packages = action.get("packages", [])
|
|
984
1008
|
if packages:
|
|
1009
|
+
if not _verify_action_confirmation(action, confirmation_token, name="repair_action"):
|
|
1010
|
+
return False, "설치 명령 확인 토큰이 일치하지 않습니다."
|
|
985
1011
|
for pkg in packages:
|
|
986
|
-
success, err = await _pip_install(pkg)
|
|
1012
|
+
success, err = await _pip_install(pkg, confirmed=True, actor=actor)
|
|
987
1013
|
if not success:
|
|
988
1014
|
return False, err
|
|
989
1015
|
return _verify_action(action)
|
|
990
1016
|
return False, "자동 복구 방법을 찾지 못했습니다."
|
|
991
1017
|
|
|
992
1018
|
|
|
993
|
-
async def install_stream(
|
|
1019
|
+
async def install_stream(
|
|
1020
|
+
items: List[Dict],
|
|
1021
|
+
router: Any,
|
|
1022
|
+
*,
|
|
1023
|
+
confirmation_token: str | None = None,
|
|
1024
|
+
user_email: str | None = None,
|
|
1025
|
+
) -> AsyncIterator[str]:
|
|
994
1026
|
for item in items:
|
|
995
1027
|
item_id = item.get("id", "unknown")
|
|
996
1028
|
name = item.get("name", item_id)
|
|
@@ -1006,10 +1038,14 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
|
|
|
1006
1038
|
|
|
1007
1039
|
if atype == "pip":
|
|
1008
1040
|
packages = action.get("packages", [])
|
|
1041
|
+
token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
|
|
1042
|
+
if not _verify_action_confirmation(action, token, name=str(item_id)):
|
|
1043
|
+
yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
|
|
1044
|
+
continue
|
|
1009
1045
|
ok = True
|
|
1010
1046
|
for pkg in packages:
|
|
1011
1047
|
yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
|
|
1012
|
-
success, err = await _pip_install(pkg)
|
|
1048
|
+
success, err = await _pip_install(pkg, confirmed=True, actor=user_email)
|
|
1013
1049
|
if success:
|
|
1014
1050
|
yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
|
|
1015
1051
|
else:
|
|
@@ -1023,13 +1059,17 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
|
|
|
1023
1059
|
yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
|
|
1024
1060
|
else:
|
|
1025
1061
|
yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
|
|
1026
|
-
repaired, repair_msg = await _repair_action(action)
|
|
1062
|
+
repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
|
|
1027
1063
|
yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
|
|
1028
1064
|
|
|
1029
1065
|
elif atype == "brew":
|
|
1030
1066
|
pkg = action.get("package", "")
|
|
1067
|
+
token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
|
|
1068
|
+
if not _verify_action_confirmation(action, token, name=str(item_id)):
|
|
1069
|
+
yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
|
|
1070
|
+
continue
|
|
1031
1071
|
yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
|
|
1032
|
-
success, err = await _brew_install(pkg)
|
|
1072
|
+
success, err = await _brew_install(pkg, confirmed=True, actor=user_email)
|
|
1033
1073
|
if success:
|
|
1034
1074
|
yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
|
|
1035
1075
|
binary = action.get("binary")
|
|
@@ -1040,7 +1080,7 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
|
|
|
1040
1080
|
yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
|
|
1041
1081
|
else:
|
|
1042
1082
|
yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
|
|
1043
|
-
repaired, repair_msg = await _repair_action(action)
|
|
1083
|
+
repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
|
|
1044
1084
|
yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
|
|
1045
1085
|
else:
|
|
1046
1086
|
url = action.get("official_url") or action.get("url")
|
|
@@ -1094,49 +1134,131 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
|
|
|
1094
1134
|
yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
|
|
1095
1135
|
|
|
1096
1136
|
|
|
1097
|
-
async def _pip_install(
|
|
1137
|
+
async def _pip_install(
|
|
1138
|
+
package: str,
|
|
1139
|
+
*,
|
|
1140
|
+
confirmation_token: str | None = None,
|
|
1141
|
+
confirmed: bool = False,
|
|
1142
|
+
actor: str | None = None,
|
|
1143
|
+
) -> Tuple[bool, str]:
|
|
1144
|
+
command = [sys.executable, "-m", "pip", "install", "--upgrade", package]
|
|
1145
|
+
plan = command_plan(
|
|
1146
|
+
command,
|
|
1147
|
+
name=f"pip:{package}",
|
|
1148
|
+
purpose="setup_wizard_install",
|
|
1149
|
+
metadata={"package": package},
|
|
1150
|
+
)
|
|
1098
1151
|
try:
|
|
1152
|
+
if not confirmed:
|
|
1153
|
+
require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
|
|
1154
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
|
|
1099
1155
|
proc = await asyncio.create_subprocess_exec(
|
|
1100
|
-
|
|
1156
|
+
*command,
|
|
1101
1157
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1102
1158
|
)
|
|
1103
1159
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
|
|
1160
|
+
stderr_text = stderr.decode(errors="replace")
|
|
1161
|
+
append_process_audit_event(
|
|
1162
|
+
"setup_wizard_install",
|
|
1163
|
+
plan=plan,
|
|
1164
|
+
status="finished",
|
|
1165
|
+
user_email=actor,
|
|
1166
|
+
returncode=proc.returncode,
|
|
1167
|
+
stderr=stderr_text,
|
|
1168
|
+
)
|
|
1104
1169
|
if proc.returncode == 0:
|
|
1105
1170
|
return True, ""
|
|
1106
|
-
return False,
|
|
1171
|
+
return False, stderr_text
|
|
1172
|
+
except CommandConfirmationError as e:
|
|
1173
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
|
|
1174
|
+
return False, str(e)
|
|
1107
1175
|
except asyncio.TimeoutError:
|
|
1176
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
|
|
1108
1177
|
return False, "설치 시간 초과 (10분)"
|
|
1109
1178
|
except Exception as e:
|
|
1179
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
|
|
1110
1180
|
return False, str(e)
|
|
1111
1181
|
|
|
1112
1182
|
|
|
1113
|
-
async def _brew_install(
|
|
1183
|
+
async def _brew_install(
|
|
1184
|
+
package: str,
|
|
1185
|
+
*,
|
|
1186
|
+
confirmation_token: str | None = None,
|
|
1187
|
+
confirmed: bool = False,
|
|
1188
|
+
actor: str | None = None,
|
|
1189
|
+
) -> Tuple[bool, str]:
|
|
1114
1190
|
brew = shutil.which("brew")
|
|
1115
1191
|
if not brew:
|
|
1116
1192
|
return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
|
|
1193
|
+
command = [brew, "install", package]
|
|
1194
|
+
plan = command_plan(
|
|
1195
|
+
command,
|
|
1196
|
+
name=f"brew:{package}",
|
|
1197
|
+
purpose="setup_wizard_install",
|
|
1198
|
+
metadata={"package": package},
|
|
1199
|
+
)
|
|
1117
1200
|
try:
|
|
1201
|
+
if not confirmed:
|
|
1202
|
+
require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
|
|
1203
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
|
|
1118
1204
|
proc = await asyncio.create_subprocess_exec(
|
|
1119
|
-
|
|
1205
|
+
*command,
|
|
1120
1206
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1121
1207
|
)
|
|
1122
1208
|
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
|
1209
|
+
stderr_text = stderr.decode(errors="replace")
|
|
1210
|
+
append_process_audit_event(
|
|
1211
|
+
"setup_wizard_install",
|
|
1212
|
+
plan=plan,
|
|
1213
|
+
status="finished",
|
|
1214
|
+
user_email=actor,
|
|
1215
|
+
returncode=proc.returncode,
|
|
1216
|
+
stderr=stderr_text,
|
|
1217
|
+
)
|
|
1123
1218
|
if proc.returncode == 0:
|
|
1124
1219
|
return True, ""
|
|
1125
|
-
return False,
|
|
1220
|
+
return False, stderr_text
|
|
1221
|
+
except CommandConfirmationError as e:
|
|
1222
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
|
|
1223
|
+
return False, str(e)
|
|
1126
1224
|
except asyncio.TimeoutError:
|
|
1225
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
|
|
1127
1226
|
return False, "설치 시간 초과 (5분)"
|
|
1128
1227
|
except Exception as e:
|
|
1228
|
+
append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
|
|
1129
1229
|
return False, str(e)
|
|
1130
1230
|
|
|
1131
1231
|
|
|
1132
1232
|
def open_url(url: str) -> None:
|
|
1233
|
+
command: List[str]
|
|
1133
1234
|
try:
|
|
1134
1235
|
system = platform.system()
|
|
1135
1236
|
if system == "Darwin":
|
|
1136
|
-
|
|
1237
|
+
command = ["open", url]
|
|
1238
|
+
plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
|
|
1239
|
+
append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
|
|
1240
|
+
subprocess.Popen(command)
|
|
1241
|
+
append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
|
|
1137
1242
|
elif system == "Windows":
|
|
1138
|
-
|
|
1243
|
+
command = ["start", "", url]
|
|
1244
|
+
plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
|
|
1245
|
+
append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
|
|
1246
|
+
subprocess.Popen(command, shell=True)
|
|
1247
|
+
append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
|
|
1139
1248
|
else:
|
|
1140
|
-
|
|
1141
|
-
|
|
1249
|
+
command = ["xdg-open", url]
|
|
1250
|
+
plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
|
|
1251
|
+
append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
|
|
1252
|
+
subprocess.Popen(command)
|
|
1253
|
+
append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
|
|
1254
|
+
except Exception as exc:
|
|
1255
|
+
try:
|
|
1256
|
+
append_process_audit_event(
|
|
1257
|
+
"setup_wizard_open_url",
|
|
1258
|
+
plan=command_plan(["open_url", url], name="open_url", purpose="setup_wizard_open_url"),
|
|
1259
|
+
status="error",
|
|
1260
|
+
error=str(exc),
|
|
1261
|
+
)
|
|
1262
|
+
except Exception:
|
|
1263
|
+
pass
|
|
1142
1264
|
pass
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED