ltcai 10.8.0 → 10.9.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 +54 -52
- package/docs/CHANGELOG.md +37 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.py +7 -6
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +222 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +17 -8
- 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 +37 -37
- package/static/app/assets/{Act-CbQNsFgc.js → Act-CS9IeqUX.js} +1 -1
- package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-3UkIEWGA.js} +1 -1
- package/static/app/assets/{Brain-BZIPsZlH.js → Brain-B22EmNqS.js} +1 -1
- package/static/app/assets/{BrainHome-BaH5MDce.js → BrainHome-CMDqgJF4.js} +2 -2
- package/static/app/assets/{BrainSignals-aDt-nSsW.js → BrainSignals-BeE8RJo3.js} +1 -1
- package/static/app/assets/{Capture-DDv940Bv.js → Capture-ZX9bQh68.js} +1 -1
- package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-86m4FCcN.js} +1 -1
- package/static/app/assets/{Library-BkShbqmN.js → Library-Bhz5LUca.js} +1 -1
- package/static/app/assets/{LivingBrain-CXb8094H.js → LivingBrain-BSa0wpFG.js} +1 -1
- package/static/app/assets/{ProductFlow-BaiqFWLO.js → ProductFlow-IP4Q-5aQ.js} +1 -1
- package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-BepjSDpN.js} +1 -1
- package/static/app/assets/{System-DCstbTxC.js → System-Bwx1h_jT.js} +1 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
- package/static/app/assets/{bot-BR0eoeXY.js → bot-DQj0-LkM.js} +1 -1
- package/static/app/assets/{brain-BmWa1wea.js → brain-D8OEwmVj.js} +1 -1
- package/static/app/assets/{button-U1nU7xX4.js → button-CmTknyAP.js} +1 -1
- package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-yTCWRziJ.js} +1 -1
- package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-Ccrva84R.js} +1 -1
- package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CbJqWTlS.js} +1 -1
- package/static/app/assets/{download-CSRwz923.js → download-CkSzbzU-.js} +1 -1
- package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-CKyjQ4PU.js} +1 -1
- package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-DAzk9um0.js} +1 -1
- package/static/app/assets/{index-BZRvGOM2.css → index-BfD-jhA9.css} +1 -1
- package/static/app/assets/{index-_ldE9TAX.js → index-CxOcwsHV.js} +3 -3
- package/static/app/assets/{input-e_r2hFAY.js → input-DcMETmZ7.js} +1 -1
- package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BVf13_25.js} +1 -1
- package/static/app/assets/{primitives-CnEAOmg2.js → primitives-CQV9Q2YM.js} +1 -1
- package/static/app/assets/search-CXGASMMH.js +1 -0
- package/static/app/assets/{share-2-DLDJJKmI.js → share-2-COWCHNZm.js} +1 -1
- package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-BDrvilyK.js} +1 -1
- package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-rUmsc8cP.js} +1 -1
- package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-Bi5UY_8v.js} +1 -1
- package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-C-AicB-3.js} +1 -1
- package/static/app/assets/{utils-XDsjcrhS.js → utils-BMwWg78e.js} +1 -1
- package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Y93tls8P.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
- package/static/app/assets/search-veep4n6f.js +0 -1
|
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
19
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
20
|
from pydantic import BaseModel, Field
|
|
21
21
|
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
22
23
|
from latticeai.services.change_proposals import ProposalConflictError
|
|
23
24
|
from latticeai.services.review_queue import InvalidReviewTransition, ReviewQueueService
|
|
24
25
|
|
|
@@ -129,7 +130,7 @@ def create_review_queue_router(
|
|
|
129
130
|
try:
|
|
130
131
|
return service.get(item_id, workspace_id=scope)
|
|
131
132
|
except FileNotFoundError as exc:
|
|
132
|
-
raise
|
|
133
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
133
134
|
|
|
134
135
|
@router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
|
|
135
136
|
async def approve_item(item_id: str, request: Request):
|
|
@@ -141,12 +142,16 @@ def create_review_queue_router(
|
|
|
141
142
|
try:
|
|
142
143
|
stored = service.get(item_id, workspace_id=scope)
|
|
143
144
|
except FileNotFoundError as exc:
|
|
144
|
-
raise
|
|
145
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
145
146
|
if stored.get("source") == "change_proposal":
|
|
146
147
|
if stored.get("effective_status") not in ("pending", "snoozed"):
|
|
147
148
|
raise HTTPException(
|
|
148
149
|
status_code=409,
|
|
149
|
-
detail=
|
|
150
|
+
detail=translate(
|
|
151
|
+
"review.cannot_approve_in_status",
|
|
152
|
+
resolve_language(request),
|
|
153
|
+
status=stored.get("status"),
|
|
154
|
+
),
|
|
150
155
|
)
|
|
151
156
|
try:
|
|
152
157
|
applied = change_proposals.approve_and_apply(
|
|
@@ -177,7 +182,7 @@ def create_review_queue_router(
|
|
|
177
182
|
item_id, workspace_id=scope, reason=(req.reason if req else None)
|
|
178
183
|
)
|
|
179
184
|
except FileNotFoundError as exc:
|
|
180
|
-
raise
|
|
185
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
181
186
|
except InvalidReviewTransition as exc:
|
|
182
187
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
183
188
|
append_audit_event("review_item_dismiss", user_email=user, item_id=item_id)
|
|
@@ -190,7 +195,7 @@ def create_review_queue_router(
|
|
|
190
195
|
try:
|
|
191
196
|
item = service.snooze(item_id, until=req.until, workspace_id=scope)
|
|
192
197
|
except FileNotFoundError as exc:
|
|
193
|
-
raise
|
|
198
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
194
199
|
except InvalidReviewTransition as exc:
|
|
195
200
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
196
201
|
append_audit_event("review_item_snooze", user_email=user, item_id=item_id)
|
|
@@ -211,7 +216,7 @@ def create_review_queue_router(
|
|
|
211
216
|
workspace_id=scope,
|
|
212
217
|
)
|
|
213
218
|
except FileNotFoundError as exc:
|
|
214
|
-
raise
|
|
219
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
215
220
|
except InvalidReviewTransition as exc:
|
|
216
221
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
217
222
|
append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
|
|
@@ -224,7 +229,7 @@ def create_review_queue_router(
|
|
|
224
229
|
try:
|
|
225
230
|
item = fn(item_id, workspace_id=scope)
|
|
226
231
|
except FileNotFoundError as exc:
|
|
227
|
-
raise
|
|
232
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
228
233
|
except InvalidReviewTransition as exc:
|
|
229
234
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
230
235
|
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|
package/latticeai/api/setup.py
CHANGED
|
@@ -9,6 +9,7 @@ from fastapi.responses import StreamingResponse
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
11
|
from lattice_brain.ingestion import IngestionItem
|
|
12
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
12
13
|
from latticeai.models.router import parse_model_ref
|
|
13
14
|
from latticeai.services.process_audit import command_plan
|
|
14
15
|
from latticeai.setup.auto_setup import (
|
|
@@ -184,27 +185,25 @@ def create_setup_router(
|
|
|
184
185
|
}
|
|
185
186
|
url = auth_urls.get(mcp_id)
|
|
186
187
|
if not url:
|
|
187
|
-
raise
|
|
188
|
+
raise http_error(404, "mcp.unknown_id", resolve_language(request), mcp_id=mcp_id)
|
|
188
189
|
open_url(url)
|
|
189
190
|
return {"status": "ok", "opened": url, "mcp_id": mcp_id}
|
|
190
191
|
|
|
191
192
|
|
|
192
193
|
# ── First Value Loop demo corpus (backlog #3, review §3.3 P0) ────────────
|
|
193
194
|
|
|
194
|
-
def _require_demo_pipeline():
|
|
195
|
+
def _require_demo_pipeline(request: Request):
|
|
195
196
|
if ingestion_pipeline is None or not ingestion_pipeline.available():
|
|
196
|
-
raise
|
|
197
|
-
status_code=503, detail="Knowledge Graph ingestion is disabled.",
|
|
198
|
-
)
|
|
197
|
+
raise http_error(503, "capture.ingestion_disabled", resolve_language(request))
|
|
199
198
|
if knowledge_graph is None:
|
|
200
|
-
raise
|
|
199
|
+
raise http_error(503, "common.graph_disabled", resolve_language(request))
|
|
201
200
|
|
|
202
201
|
def _demo_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
|
|
203
202
|
header = request.headers.get("X-Workspace-Id")
|
|
204
203
|
header = header.strip() if header and header.strip() else None
|
|
205
204
|
supplied = [value for value in (body_workspace, header) if value]
|
|
206
205
|
if len(set(supplied)) > 1:
|
|
207
|
-
raise
|
|
206
|
+
raise http_error(403, "common.workspace_mismatch", resolve_language(request))
|
|
208
207
|
requested = supplied[0] if supplied else None
|
|
209
208
|
if workspace_service is None:
|
|
210
209
|
return requested
|
|
@@ -217,7 +216,7 @@ def create_setup_router(
|
|
|
217
216
|
async def demo_corpus_status(request: Request):
|
|
218
217
|
"""Whether the demo corpus is installed + the suggestion chips."""
|
|
219
218
|
require_user(request)
|
|
220
|
-
_require_demo_pipeline()
|
|
219
|
+
_require_demo_pipeline(request)
|
|
221
220
|
installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
|
|
222
221
|
return {
|
|
223
222
|
"installed": bool(installed),
|
|
@@ -236,7 +235,7 @@ def create_setup_router(
|
|
|
236
235
|
instead of duplicating.
|
|
237
236
|
"""
|
|
238
237
|
user = require_user(request)
|
|
239
|
-
_require_demo_pipeline()
|
|
238
|
+
_require_demo_pipeline(request)
|
|
240
239
|
workspace_id = _demo_workspace(request, req.workspace_id if req else None, user)
|
|
241
240
|
results = []
|
|
242
241
|
ingested = 0
|
|
@@ -285,7 +284,7 @@ def create_setup_router(
|
|
|
285
284
|
async def demo_corpus_remove(request: Request):
|
|
286
285
|
"""Remove every demo document (node + chunks + edges + orphan source)."""
|
|
287
286
|
require_user(request)
|
|
288
|
-
_require_demo_pipeline()
|
|
287
|
+
_require_demo_pipeline(request)
|
|
289
288
|
installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
|
|
290
289
|
removed = []
|
|
291
290
|
for doc in installed:
|
|
@@ -314,7 +313,7 @@ def create_setup_router(
|
|
|
314
313
|
}
|
|
315
314
|
url = urls.get(permission_id)
|
|
316
315
|
if not url:
|
|
317
|
-
raise
|
|
316
|
+
raise http_error(404, "setup.unknown_permission", resolve_language(request))
|
|
318
317
|
open_url(url)
|
|
319
318
|
return {"status": "ok", "opened": url, "permission": permission_id}
|
|
320
319
|
return api_router
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import hashlib
|
|
6
7
|
import hmac
|
|
8
|
+
import re
|
|
7
9
|
import secrets
|
|
8
10
|
import subprocess
|
|
9
11
|
import time
|
|
@@ -40,6 +42,59 @@ SYSINFO_READINESS_ROOMY_MAX = 55.0
|
|
|
40
42
|
SYSINFO_READINESS_TIGHT_MAX = 80.0
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
def _probe_host_capacity() -> Dict[str, Any]:
|
|
46
|
+
"""Sample CPU / RAM / GPU on this machine. Blocking — call off the loop.
|
|
47
|
+
|
|
48
|
+
Extracted from the route so the subprocess sampling can be handed to a
|
|
49
|
+
worker thread, and so the parsing is testable without an HTTP request.
|
|
50
|
+
"""
|
|
51
|
+
result: Dict[str, Any] = {
|
|
52
|
+
"cpu_pct": 0.0,
|
|
53
|
+
"ram_pct": 0.0,
|
|
54
|
+
"gpu_mem_pct": 0.0,
|
|
55
|
+
"gpu_mem_gb": 0.0,
|
|
56
|
+
"readiness": "roomy",
|
|
57
|
+
}
|
|
58
|
+
try:
|
|
59
|
+
# CPU
|
|
60
|
+
top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
|
|
61
|
+
for line in top_out.splitlines():
|
|
62
|
+
if "CPU usage" in line:
|
|
63
|
+
m = re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
|
|
64
|
+
if m:
|
|
65
|
+
result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
|
|
66
|
+
# RAM
|
|
67
|
+
vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
|
|
68
|
+
pages: dict = {}
|
|
69
|
+
for line in vm_out.splitlines():
|
|
70
|
+
for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
|
|
71
|
+
if line.startswith(key):
|
|
72
|
+
m = re.search(r"(\d+)", line)
|
|
73
|
+
if m:
|
|
74
|
+
pages[key] = int(m.group(1))
|
|
75
|
+
total = sum(pages.values())
|
|
76
|
+
used = total - pages.get("Pages free", 0)
|
|
77
|
+
result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
|
|
78
|
+
# GPU (MLX / Apple Silicon unified memory)
|
|
79
|
+
try:
|
|
80
|
+
import mlx.core as _mx
|
|
81
|
+
hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
|
|
82
|
+
total_bytes = int(hw_out.strip())
|
|
83
|
+
gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
|
|
84
|
+
result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
|
|
85
|
+
result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
|
|
86
|
+
except Exception:
|
|
87
|
+
quiet()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
result["error"] = str(e)
|
|
90
|
+
result["readiness"] = host_capacity_readiness(
|
|
91
|
+
cpu_pct=float(result.get("cpu_pct") or 0.0),
|
|
92
|
+
ram_pct=float(result.get("ram_pct") or 0.0),
|
|
93
|
+
gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
|
|
94
|
+
)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
43
98
|
def host_capacity_readiness(
|
|
44
99
|
*,
|
|
45
100
|
cpu_pct: float = 0.0,
|
|
@@ -278,54 +333,15 @@ def create_static_routes_router(
|
|
|
278
333
|
|
|
279
334
|
``readiness`` is ``roomy`` | ``tight`` | ``low`` so basic System copy
|
|
280
335
|
does not re-interpret raw percents on the client.
|
|
336
|
+
|
|
337
|
+
The probe itself is three subprocesses — ``top -l 1`` alone samples for
|
|
338
|
+
about a second — so it runs on a worker thread. Run inline it held the
|
|
339
|
+
event loop for the whole sample, and this route is hit on the System
|
|
340
|
+
screen *and* during first-run analysis, i.e. exactly while an answer is
|
|
341
|
+
streaming: one status poll used to stall every open stream.
|
|
281
342
|
"""
|
|
282
343
|
require_user(request)
|
|
283
|
-
|
|
284
|
-
result: Dict[str, Any] = {
|
|
285
|
-
"cpu_pct": 0.0,
|
|
286
|
-
"ram_pct": 0.0,
|
|
287
|
-
"gpu_mem_pct": 0.0,
|
|
288
|
-
"gpu_mem_gb": 0.0,
|
|
289
|
-
"readiness": "roomy",
|
|
290
|
-
}
|
|
291
|
-
try:
|
|
292
|
-
# CPU
|
|
293
|
-
top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
|
|
294
|
-
for line in top_out.splitlines():
|
|
295
|
-
if "CPU usage" in line:
|
|
296
|
-
m = _re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
|
|
297
|
-
if m:
|
|
298
|
-
result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
|
|
299
|
-
# RAM
|
|
300
|
-
vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
|
|
301
|
-
pages: dict = {}
|
|
302
|
-
for line in vm_out.splitlines():
|
|
303
|
-
for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
|
|
304
|
-
if line.startswith(key):
|
|
305
|
-
m = _re.search(r"(\d+)", line)
|
|
306
|
-
if m:
|
|
307
|
-
pages[key] = int(m.group(1))
|
|
308
|
-
total = sum(pages.values())
|
|
309
|
-
used = total - pages.get("Pages free", 0)
|
|
310
|
-
result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
|
|
311
|
-
# GPU (MLX / Apple Silicon unified memory)
|
|
312
|
-
try:
|
|
313
|
-
import mlx.core as _mx
|
|
314
|
-
hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
|
|
315
|
-
total_bytes = int(hw_out.strip())
|
|
316
|
-
gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
|
|
317
|
-
result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
|
|
318
|
-
result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
|
|
319
|
-
except Exception:
|
|
320
|
-
quiet()
|
|
321
|
-
except Exception as e:
|
|
322
|
-
result["error"] = str(e)
|
|
323
|
-
result["readiness"] = host_capacity_readiness(
|
|
324
|
-
cpu_pct=float(result.get("cpu_pct") or 0.0),
|
|
325
|
-
ram_pct=float(result.get("ram_pct") or 0.0),
|
|
326
|
-
gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
|
|
327
|
-
)
|
|
328
|
-
return result
|
|
344
|
+
return await asyncio.to_thread(_probe_host_capacity)
|
|
329
345
|
|
|
330
346
|
return StaticRoutesBundle(
|
|
331
347
|
api_router,
|
package/latticeai/api/tools.py
CHANGED
|
@@ -19,6 +19,7 @@ from latticeai.api.computer_use import create_computer_use_router
|
|
|
19
19
|
from latticeai.api.local_files import create_local_files_router
|
|
20
20
|
from latticeai.api.mcp import create_mcp_router
|
|
21
21
|
from latticeai.api.permissions import create_permissions_router
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
22
23
|
from latticeai.services.router_context import ToolRouterContext
|
|
23
24
|
from latticeai.services.tool_dispatch import (
|
|
24
25
|
TOOL_GOVERNANCE,
|
|
@@ -510,7 +511,7 @@ def create_tools_router(
|
|
|
510
511
|
)
|
|
511
512
|
target = Path(path).expanduser().resolve()
|
|
512
513
|
if not target.exists() or not target.is_file():
|
|
513
|
-
raise
|
|
514
|
+
raise http_error(404, "common.file_not_found", resolve_language(request))
|
|
514
515
|
import pypdfium2 as pdfium
|
|
515
516
|
doc = None
|
|
516
517
|
try:
|
|
@@ -527,7 +528,7 @@ def create_tools_router(
|
|
|
527
528
|
pages.append({"page": i + 1, "b64": b64})
|
|
528
529
|
return {"total": total, "pages": pages}
|
|
529
530
|
except Exception as e:
|
|
530
|
-
raise
|
|
531
|
+
raise http_error(500, "tools.pdf_render_failed", resolve_language(request), reason=e)
|
|
531
532
|
finally:
|
|
532
533
|
if doc is not None:
|
|
533
534
|
try:
|
|
@@ -544,9 +545,9 @@ def create_tools_router(
|
|
|
544
545
|
rel = unquote(path).lstrip("/")
|
|
545
546
|
target = (AGENT_ROOT / rel).resolve()
|
|
546
547
|
if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
|
|
547
|
-
raise
|
|
548
|
+
raise http_error(403, "tools.path_outside_workspace", resolve_language(request))
|
|
548
549
|
if not target.exists() or not target.is_file():
|
|
549
|
-
raise
|
|
550
|
+
raise http_error(404, "common.file_not_found", resolve_language(request))
|
|
550
551
|
return FileResponse(
|
|
551
552
|
path=target,
|
|
552
553
|
filename=target.name,
|
|
@@ -570,9 +571,9 @@ def create_tools_router(
|
|
|
570
571
|
rel = unquote(path).lstrip("/")
|
|
571
572
|
target = (AGENT_ROOT / rel).resolve()
|
|
572
573
|
if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
|
|
573
|
-
raise
|
|
574
|
+
raise http_error(403, "tools.path_outside_workspace", resolve_language(request))
|
|
574
575
|
if not target.exists() or not target.is_dir():
|
|
575
|
-
raise
|
|
576
|
+
raise http_error(404, "tools.directory_not_found", resolve_language(request))
|
|
576
577
|
try:
|
|
577
578
|
payload, filename = zip_workspace_dir(rel)
|
|
578
579
|
except ToolError as exc:
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from copy import deepcopy
|
|
11
11
|
from typing import Any, Dict, List, Optional
|
|
12
12
|
|
|
13
|
-
MARKETPLACE_VERSION = "10.
|
|
13
|
+
MARKETPLACE_VERSION = "10.9.0"
|
|
14
14
|
TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
|
|
15
15
|
|
|
16
16
|
|
|
@@ -4,6 +4,7 @@ Lattice AI — MCP Registry data & pure helper functions.
|
|
|
4
4
|
Extracted from server.py to reduce module size.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
@@ -354,6 +355,13 @@ async def install_skill(plugin: str, skill: str) -> Dict:
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
|
|
358
|
+
def _run_installer(command: List[str], timeout: int) -> "subprocess.CompletedProcess[str]":
|
|
359
|
+
"""Run one package-installer command. Blocking — call off the event loop."""
|
|
360
|
+
return subprocess.run(
|
|
361
|
+
command, capture_output=True, text=True, timeout=timeout, check=False
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
357
365
|
def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
358
366
|
"""Return bound MCP state helpers for given data_dir. No global side effects."""
|
|
359
367
|
MCP_FILE = Path(data_dir) / "mcp_installs.json"
|
|
@@ -445,11 +453,15 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
|
445
453
|
status = "needs_auth"
|
|
446
454
|
message = "커넥터 인증이 필요합니다. Codex 앱의 connector 설정에서 계정을 연결하면 바로 사용할 수 있습니다."
|
|
447
455
|
elif item.get("install_mode") == "pip":
|
|
456
|
+
# Package installers are minutes of network I/O. On the event loop
|
|
457
|
+
# they froze every other request for the whole install; each one now
|
|
458
|
+
# runs on a worker thread instead.
|
|
448
459
|
packages = item.get("pip_packages") or []
|
|
449
460
|
for pkg in packages:
|
|
450
|
-
completed =
|
|
461
|
+
completed = await asyncio.to_thread(
|
|
462
|
+
_run_installer,
|
|
451
463
|
[sys.executable, "-m", "pip", "install", "--upgrade", pkg],
|
|
452
|
-
|
|
464
|
+
900,
|
|
453
465
|
)
|
|
454
466
|
if completed.returncode != 0:
|
|
455
467
|
raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
|
|
@@ -458,7 +470,9 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
|
458
470
|
pkg = item.get("package", "")
|
|
459
471
|
version = item.get("package_version")
|
|
460
472
|
pkg_str = f"{pkg}=={version}" if version else pkg
|
|
461
|
-
completed =
|
|
473
|
+
completed = await asyncio.to_thread(
|
|
474
|
+
_run_installer, [sys.executable, "-m", "pip", "install", pkg_str], 300
|
|
475
|
+
)
|
|
462
476
|
if completed.returncode != 0:
|
|
463
477
|
raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
|
|
464
478
|
message = f"pip 패키지 설치 완료: {pkg_str}"
|
|
@@ -466,7 +480,9 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
|
466
480
|
pkg = item.get("package", "")
|
|
467
481
|
version = item.get("package_version")
|
|
468
482
|
pkg_str = f"{pkg}@{version}" if version else pkg
|
|
469
|
-
completed =
|
|
483
|
+
completed = await asyncio.to_thread(
|
|
484
|
+
_run_installer, ["npm", "install", "-g", pkg_str], 300
|
|
485
|
+
)
|
|
470
486
|
if completed.returncode != 0:
|
|
471
487
|
raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
|
|
472
488
|
message = f"npm 패키지 설치 완료: {pkg_str}"
|
|
@@ -155,6 +155,228 @@ MESSAGES: Dict[str, Dict[str, str]] = {
|
|
|
155
155
|
"ko": "저장할 내용이 없습니다. 텍스트나 페이지 내용을 함께 보내주세요.",
|
|
156
156
|
"en": "Nothing to capture — send text, html, or a selection.",
|
|
157
157
|
},
|
|
158
|
+
# ── shared ──────────────────────────────────────────────────────────
|
|
159
|
+
"common.user_mismatch": {
|
|
160
|
+
"ko": "로그인한 사용자와 요청한 사용자가 다릅니다.",
|
|
161
|
+
"en": "user_email must match the authenticated user.",
|
|
162
|
+
},
|
|
163
|
+
"common.workspace_mismatch": {
|
|
164
|
+
"ko": "작업 공간 지정이 서로 다릅니다.",
|
|
165
|
+
"en": "Workspace selectors must match.",
|
|
166
|
+
},
|
|
167
|
+
"common.workspace_unreadable": {
|
|
168
|
+
"ko": "'{workspace}' 작업 공간을 읽을 권한이 없습니다.",
|
|
169
|
+
"en": "Workspace '{workspace}' is not readable.",
|
|
170
|
+
},
|
|
171
|
+
"common.file_not_found": {
|
|
172
|
+
"ko": "파일을 찾을 수 없습니다.",
|
|
173
|
+
"en": "File not found.",
|
|
174
|
+
},
|
|
175
|
+
"common.path_required": {
|
|
176
|
+
"ko": "경로를 입력해주세요.",
|
|
177
|
+
"en": "A path is required.",
|
|
178
|
+
},
|
|
179
|
+
"common.graph_disabled": {
|
|
180
|
+
"ko": "지식 그래프가 꺼져 있습니다.",
|
|
181
|
+
"en": "The Knowledge Graph is turned off.",
|
|
182
|
+
},
|
|
183
|
+
"common.graph_unavailable": {
|
|
184
|
+
"ko": "지식 그래프를 사용할 수 없습니다.",
|
|
185
|
+
"en": "The Knowledge Graph is not available.",
|
|
186
|
+
},
|
|
187
|
+
# ── chat ────────────────────────────────────────────────────────────
|
|
188
|
+
"chat.model_not_loaded": {
|
|
189
|
+
"ko": "'{model}' 모델이 아직 준비되지 않았습니다.",
|
|
190
|
+
"en": "Model '{model}' is not loaded.",
|
|
191
|
+
},
|
|
192
|
+
"chat.no_model_loaded": {
|
|
193
|
+
"ko": "불러온 모델이 없습니다. 모델을 먼저 준비해주세요.",
|
|
194
|
+
"en": "No model is loaded. Prepare a model first.",
|
|
195
|
+
},
|
|
196
|
+
"chat.conversation_not_found": {
|
|
197
|
+
"ko": "대화를 찾을 수 없습니다.",
|
|
198
|
+
"en": "That conversation no longer exists.",
|
|
199
|
+
},
|
|
200
|
+
"chat.file_name_collision": {
|
|
201
|
+
"ko": "'{name}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
|
|
202
|
+
"en": "Too many files are already named '{name}'. Please choose another name.",
|
|
203
|
+
},
|
|
204
|
+
"chat.file_generation_failed": {
|
|
205
|
+
"ko": "파일 내용을 만들지 못했습니다.",
|
|
206
|
+
"en": "The file content could not be generated.",
|
|
207
|
+
},
|
|
208
|
+
# ── memory / graph ──────────────────────────────────────────────────
|
|
209
|
+
"memory.unknown_source": {
|
|
210
|
+
"ko": "'{source}'는 알 수 없는 기억 출처입니다.",
|
|
211
|
+
"en": "Unknown memory source: {source}.",
|
|
212
|
+
},
|
|
213
|
+
"graph.node_id_required": {
|
|
214
|
+
"ko": "노드 식별자가 필요합니다.",
|
|
215
|
+
"en": "A node id is required.",
|
|
216
|
+
},
|
|
217
|
+
"graph.node_not_found": {
|
|
218
|
+
"ko": "노드를 찾을 수 없습니다.",
|
|
219
|
+
"en": "That node was not found.",
|
|
220
|
+
},
|
|
221
|
+
"graph.unsupported_type": {
|
|
222
|
+
"ko": "지원하는 종류: message, ai_response, note",
|
|
223
|
+
"en": "Supported types are: message, ai_response, note.",
|
|
224
|
+
},
|
|
225
|
+
# ── ingestion / folders ─────────────────────────────────────────────
|
|
226
|
+
"ingestion.job_not_found": {
|
|
227
|
+
"ko": "수집 작업을 찾을 수 없습니다.",
|
|
228
|
+
"en": "That ingestion job was not found.",
|
|
229
|
+
},
|
|
230
|
+
"ingestion.watch_unavailable": {
|
|
231
|
+
"ko": "폴더 감시 기능을 사용할 수 없습니다.",
|
|
232
|
+
"en": "The folder watch service is unavailable.",
|
|
233
|
+
},
|
|
234
|
+
"ingestion.watch_enable_failed": {
|
|
235
|
+
"ko": "폴더 감시를 켜지 못했습니다.",
|
|
236
|
+
"en": "Turning folder watch on did not succeed.",
|
|
237
|
+
},
|
|
238
|
+
"ingestion.watch_selector_required": {
|
|
239
|
+
"ko": "감시 항목 또는 경로가 필요합니다.",
|
|
240
|
+
"en": "A watch id or a path is required.",
|
|
241
|
+
},
|
|
242
|
+
"ingestion.watch_not_found": {
|
|
243
|
+
"ko": "감시 중인 폴더를 찾을 수 없습니다.",
|
|
244
|
+
"en": "That watched folder was not found.",
|
|
245
|
+
},
|
|
246
|
+
"portability.verification_failed": {
|
|
247
|
+
"ko": "보관 파일 검증에 실패했습니다.",
|
|
248
|
+
"en": "Archive verification failed.",
|
|
249
|
+
},
|
|
250
|
+
# ── review queue ────────────────────────────────────────────────────
|
|
251
|
+
"review.item_not_found": {
|
|
252
|
+
"ko": "검토 항목을 찾을 수 없습니다.",
|
|
253
|
+
"en": "That review item was not found.",
|
|
254
|
+
},
|
|
255
|
+
"review.cannot_approve_in_status": {
|
|
256
|
+
"ko": "'{status}' 상태의 검토 항목은 승인할 수 없습니다.",
|
|
257
|
+
"en": "A review item in status '{status}' cannot be approved.",
|
|
258
|
+
},
|
|
259
|
+
# ── projects ────────────────────────────────────────────────────────
|
|
260
|
+
"project.not_found": {
|
|
261
|
+
"ko": "프로젝트를 찾을 수 없습니다.",
|
|
262
|
+
"en": "That project was not found.",
|
|
263
|
+
},
|
|
264
|
+
# ── network boundary ────────────────────────────────────────────────
|
|
265
|
+
"boundary.policy_not_configured": {
|
|
266
|
+
"ko": "혼합 검색 정책 서비스가 설정되지 않았습니다.",
|
|
267
|
+
"en": "The hybrid policy service is not configured.",
|
|
268
|
+
},
|
|
269
|
+
# ── models ──────────────────────────────────────────────────────────
|
|
270
|
+
"models.other_user_credentials": {
|
|
271
|
+
"ko": "다른 사용자의 모델 자격 증명을 사용할 수 없습니다.",
|
|
272
|
+
"en": "You cannot use another user's model credentials.",
|
|
273
|
+
},
|
|
274
|
+
"models.download_consent_required": {
|
|
275
|
+
"ko": "모델 내려받기는 사용자가 직접 동의해야 시작됩니다.",
|
|
276
|
+
"en": "Model downloads require explicit consent (allow_download=true).",
|
|
277
|
+
},
|
|
278
|
+
"models.identifier_empty": {
|
|
279
|
+
"ko": "모델 식별자가 비어 있습니다.",
|
|
280
|
+
"en": "The model identifier is empty.",
|
|
281
|
+
},
|
|
282
|
+
"models.name_empty": {
|
|
283
|
+
"ko": "모델 이름이 비어 있습니다.",
|
|
284
|
+
"en": "The model name is empty.",
|
|
285
|
+
},
|
|
286
|
+
"models.ollama_missing": {
|
|
287
|
+
"ko": "Ollama가 설치되어 있지 않습니다.",
|
|
288
|
+
"en": "Ollama is not installed.",
|
|
289
|
+
},
|
|
290
|
+
"models.download_timeout": {
|
|
291
|
+
"ko": "모델 내려받기 시간이 초과되었습니다.",
|
|
292
|
+
"en": "The model download timed out.",
|
|
293
|
+
},
|
|
294
|
+
"models.pull_failed": {
|
|
295
|
+
"ko": "모델 내려받기에 실패했습니다.",
|
|
296
|
+
"en": "The model download failed.",
|
|
297
|
+
},
|
|
298
|
+
"models.download_not_automated": {
|
|
299
|
+
"ko": "{provider} 엔진의 모델 내려받기는 아직 자동화되지 않았습니다.",
|
|
300
|
+
"en": "Model downloads for the {provider} engine are not automated yet.",
|
|
301
|
+
},
|
|
302
|
+
"models.unknown_provider": {
|
|
303
|
+
"ko": "알 수 없는 제공자입니다.",
|
|
304
|
+
"en": "Unknown provider.",
|
|
305
|
+
},
|
|
306
|
+
"models.api_key_empty": {
|
|
307
|
+
"ko": "API 키가 비어 있습니다.",
|
|
308
|
+
"en": "The API key is empty.",
|
|
309
|
+
},
|
|
310
|
+
"models.other_user_api_key": {
|
|
311
|
+
"ko": "다른 사용자의 API 키를 설정할 권한이 없습니다.",
|
|
312
|
+
"en": "You are not allowed to set another user's API key.",
|
|
313
|
+
},
|
|
314
|
+
"models.sign_in_required": {
|
|
315
|
+
"ko": "사용자 확인이 필요합니다. 로그인 후 다시 시도하세요.",
|
|
316
|
+
"en": "Sign in first, then try again.",
|
|
317
|
+
},
|
|
318
|
+
# ── tools / files ───────────────────────────────────────────────────
|
|
319
|
+
"tools.pdf_render_failed": {
|
|
320
|
+
"ko": "PDF를 그리지 못했습니다: {reason}",
|
|
321
|
+
"en": "The PDF could not be rendered: {reason}",
|
|
322
|
+
},
|
|
323
|
+
"tools.path_outside_workspace": {
|
|
324
|
+
"ko": "경로가 작업 공간 밖입니다.",
|
|
325
|
+
"en": "That path is outside the workspace.",
|
|
326
|
+
},
|
|
327
|
+
"tools.directory_not_found": {
|
|
328
|
+
"ko": "디렉터리를 찾을 수 없습니다.",
|
|
329
|
+
"en": "That directory was not found.",
|
|
330
|
+
},
|
|
331
|
+
# ── MCP / setup ─────────────────────────────────────────────────────
|
|
332
|
+
"mcp.connector_not_found": {
|
|
333
|
+
"ko": "커넥터를 찾을 수 없습니다.",
|
|
334
|
+
"en": "That connector was not found.",
|
|
335
|
+
},
|
|
336
|
+
"mcp.name_required": {
|
|
337
|
+
"ko": "이름은 필수입니다.",
|
|
338
|
+
"en": "A name is required.",
|
|
339
|
+
},
|
|
340
|
+
"mcp.package_required": {
|
|
341
|
+
"ko": "패키지는 필수입니다.",
|
|
342
|
+
"en": "A package is required.",
|
|
343
|
+
},
|
|
344
|
+
"mcp.item_not_found": {
|
|
345
|
+
"ko": "항목을 찾을 수 없습니다.",
|
|
346
|
+
"en": "That item was not found.",
|
|
347
|
+
},
|
|
348
|
+
"mcp.unknown_id": {
|
|
349
|
+
"ko": "알 수 없는 MCP입니다: {mcp_id}",
|
|
350
|
+
"en": "Unknown MCP: {mcp_id}",
|
|
351
|
+
},
|
|
352
|
+
"setup.unknown_permission": {
|
|
353
|
+
"ko": "알 수 없는 권한 설정입니다.",
|
|
354
|
+
"en": "Unknown permission setting.",
|
|
355
|
+
},
|
|
356
|
+
"models.public_mode_blocks_local": {
|
|
357
|
+
"ko": (
|
|
358
|
+
"공개 모드에서는 로컬 MLX 모델을 불러올 수 없습니다. "
|
|
359
|
+
"openai:, openrouter:, groq:, together: 모델을 쓰거나 "
|
|
360
|
+
"LATTICEAI_ALLOW_LOCAL_MODELS=true 로 설정하세요."
|
|
361
|
+
),
|
|
362
|
+
"en": (
|
|
363
|
+
"Public mode does not load local MLX models. Use an openai:, "
|
|
364
|
+
"openrouter:, groq: or together: model, or set "
|
|
365
|
+
"LATTICEAI_ALLOW_LOCAL_MODELS=true."
|
|
366
|
+
),
|
|
367
|
+
},
|
|
368
|
+
"models.public_model_missing": {
|
|
369
|
+
"ko": (
|
|
370
|
+
"공개 모델이 준비되지 않았습니다. OPENAI_API_KEY 와 "
|
|
371
|
+
"LATTICEAI_PUBLIC_MODEL={model} 을 설정하거나, OpenAI 호환 모델로 "
|
|
372
|
+
"/models/load 를 호출하세요."
|
|
373
|
+
),
|
|
374
|
+
"en": (
|
|
375
|
+
"No public model is loaded. Set OPENAI_API_KEY and "
|
|
376
|
+
"LATTICEAI_PUBLIC_MODEL={model}, or call /models/load with an "
|
|
377
|
+
"OpenAI-compatible model."
|
|
378
|
+
),
|
|
379
|
+
},
|
|
158
380
|
}
|
|
159
381
|
|
|
160
382
|
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from typing import Dict
|
|
12
12
|
|
|
13
|
-
WORKSPACE_OS_VERSION = "10.
|
|
13
|
+
WORKSPACE_OS_VERSION = "10.9.0"
|
|
14
14
|
|
|
15
15
|
# Workspace types separate single-user Personal workspaces from shared
|
|
16
16
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|