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.
- package/README.md +81 -58
- package/auto_setup.py +7 -904
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +221 -238
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -14
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +293 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +1126 -0
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +141 -610
- package/lattice_brain/graph/retrieval_docgen.py +243 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +69 -4
- package/lattice_brain/portability.py +29 -23
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +169 -7
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +46 -0
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +355 -952
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +250 -0
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +211 -40
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +70 -29
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +97 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +185 -405
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +45 -0
- package/latticeai/core/legacy_compatibility.py +24 -3
- 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/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +5 -14
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +2 -21
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +96 -40
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +189 -173
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +22 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +163 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +268 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +92 -24
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +76 -11
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +25 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +7 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1304
- 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-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-fZokUnC0.js +0 -1
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
- package/static/app/assets/Library-C9kyFkSt.js +0 -1
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
- package/static/app/assets/textarea-CD8UNKIy.js +0 -1
- package/tools/knowledge.py +0 -95
package/latticeai/api/models.py
CHANGED
|
@@ -5,10 +5,8 @@ Extracted from ``server_app.py`` in v1.3.0. Paths and schemas unchanged:
|
|
|
5
5
|
``/setup/set-api-key``.
|
|
6
6
|
|
|
7
7
|
Mirrors the established router-factory convention: the heavy provider/runtime
|
|
8
|
-
helpers
|
|
9
|
-
|
|
10
|
-
as callables, so this module has no import cycle and adds no import-time
|
|
11
|
-
side effects.
|
|
8
|
+
helpers are injected as bound service callables. This module owns the sole
|
|
9
|
+
translation from transport-neutral ``ModelRuntimeError`` failures to HTTP.
|
|
12
10
|
"""
|
|
13
11
|
|
|
14
12
|
from __future__ import annotations
|
|
@@ -16,12 +14,14 @@ from __future__ import annotations
|
|
|
16
14
|
import asyncio
|
|
17
15
|
import logging
|
|
18
16
|
import subprocess
|
|
19
|
-
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
from typing import Any, Callable, Dict, List, NoReturn, Optional
|
|
20
18
|
|
|
21
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
22
20
|
from fastapi.responses import StreamingResponse
|
|
23
21
|
from pydantic import BaseModel
|
|
24
22
|
|
|
23
|
+
from latticeai.services.model_errors import ModelRuntimeError
|
|
24
|
+
|
|
25
25
|
|
|
26
26
|
def _vision_capability(current_model_id: Optional[str], engines: Any) -> Dict[str, Any]:
|
|
27
27
|
"""Whether the active model can accept image input (VLM).
|
|
@@ -97,6 +97,7 @@ def create_models_router(
|
|
|
97
97
|
*,
|
|
98
98
|
model_router: Any,
|
|
99
99
|
require_user: Callable[[Request], str],
|
|
100
|
+
require_admin: Callable[[Request], tuple],
|
|
100
101
|
get_current_user: Callable[[Request], Optional[str]],
|
|
101
102
|
load_users: Callable[[], Dict],
|
|
102
103
|
get_user_role: Callable[..., str],
|
|
@@ -131,6 +132,27 @@ def create_models_router(
|
|
|
131
132
|
REQUIRE_AUTH = require_auth
|
|
132
133
|
_list_compat_profiles = list_compat_profiles
|
|
133
134
|
|
|
135
|
+
def _normalized_identity(value: Optional[str]) -> str:
|
|
136
|
+
return str(value or "").strip().lower()
|
|
137
|
+
|
|
138
|
+
def _authorize_model_admin(request: Request, claimed_email: Optional[str] = None) -> str:
|
|
139
|
+
"""Authenticate model operations and gate host-global state to admins."""
|
|
140
|
+
current_user = require_user(request)
|
|
141
|
+
if REQUIRE_AUTH:
|
|
142
|
+
if claimed_email and _normalized_identity(claimed_email) != _normalized_identity(current_user):
|
|
143
|
+
raise HTTPException(status_code=403, detail="다른 사용자의 모델 자격 증명을 사용할 수 없습니다.")
|
|
144
|
+
require_admin(request)
|
|
145
|
+
return current_user
|
|
146
|
+
|
|
147
|
+
def _effective_email(current_user: str, claimed_email: Optional[str]) -> Optional[str]:
|
|
148
|
+
# Authenticated callers may only act as their session identity. The
|
|
149
|
+
# legacy body field remains usable solely in explicit no-auth/local
|
|
150
|
+
# mode for backward compatibility.
|
|
151
|
+
return current_user if REQUIRE_AUTH else claimed_email or current_user or None
|
|
152
|
+
|
|
153
|
+
def _raise_model_http(exc: ModelRuntimeError) -> NoReturn:
|
|
154
|
+
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
|
155
|
+
|
|
134
156
|
def _recommended_with_engine_options(
|
|
135
157
|
items: List[Dict[str, object]],
|
|
136
158
|
engines: Optional[List[Dict[str, object]]] = None,
|
|
@@ -281,20 +303,26 @@ def create_models_router(
|
|
|
281
303
|
|
|
282
304
|
@router.post("/engines/install")
|
|
283
305
|
async def engines_install(req: InstallEngineRequest, request: Request):
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
306
|
+
_authorize_model_admin(request)
|
|
307
|
+
try:
|
|
308
|
+
if req.confirmation_token:
|
|
309
|
+
return install_engine(req.engine, confirmation_token=req.confirmation_token)
|
|
310
|
+
return install_engine(req.engine)
|
|
311
|
+
except ModelRuntimeError as exc:
|
|
312
|
+
_raise_model_http(exc)
|
|
288
313
|
|
|
289
314
|
@router.post("/engines/verify-cloud")
|
|
290
315
|
async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
|
|
291
|
-
|
|
292
|
-
|
|
316
|
+
_authorize_model_admin(request)
|
|
317
|
+
try:
|
|
318
|
+
results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
|
|
319
|
+
except ModelRuntimeError as exc:
|
|
320
|
+
_raise_model_http(exc)
|
|
293
321
|
return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
|
|
294
322
|
|
|
295
323
|
@router.post("/engines/pull-model")
|
|
296
324
|
async def pull_ollama_model(req: PullModelRequest, request: Request):
|
|
297
|
-
|
|
325
|
+
_authorize_model_admin(request)
|
|
298
326
|
if not req.allow_download:
|
|
299
327
|
raise HTTPException(
|
|
300
328
|
status_code=403,
|
|
@@ -315,7 +343,10 @@ def create_models_router(
|
|
|
315
343
|
raise HTTPException(status_code=400, detail="모델 이름이 비어 있습니다.")
|
|
316
344
|
|
|
317
345
|
if provider == "ollama":
|
|
318
|
-
|
|
346
|
+
try:
|
|
347
|
+
ensure_ollama_server()
|
|
348
|
+
except ModelRuntimeError as exc:
|
|
349
|
+
_raise_model_http(exc)
|
|
319
350
|
ollama = local_binary("ollama")
|
|
320
351
|
if not ollama:
|
|
321
352
|
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
@@ -342,19 +373,25 @@ def create_models_router(
|
|
|
342
373
|
|
|
343
374
|
if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
|
|
344
375
|
download_provider = "local_mlx" if provider == "mlx" else provider
|
|
345
|
-
|
|
376
|
+
try:
|
|
377
|
+
result = download_hf_model(model_name, download_provider)
|
|
378
|
+
except ModelRuntimeError as exc:
|
|
379
|
+
_raise_model_http(exc)
|
|
346
380
|
return {"provider": provider, "model": model_name, "returncode": 0, **result}
|
|
347
381
|
|
|
348
382
|
raise HTTPException(status_code=400, detail=f"{provider} 엔진 모델 다운로드는 아직 자동화되지 않았습니다.")
|
|
349
383
|
|
|
350
384
|
@router.post("/engines/prepare-model")
|
|
351
385
|
async def engines_prepare_model(req: PrepareModelRequest, request: Request):
|
|
352
|
-
|
|
386
|
+
current_user = _authorize_model_admin(request, req.user_email)
|
|
353
387
|
try:
|
|
354
388
|
return await prepare_and_load_model(
|
|
355
|
-
req.model, request, engine=req.engine,
|
|
389
|
+
req.model, request, engine=req.engine,
|
|
390
|
+
user_email=_effective_email(current_user, req.user_email),
|
|
356
391
|
allow_download=req.allow_download,
|
|
357
392
|
)
|
|
393
|
+
except ModelRuntimeError as exc:
|
|
394
|
+
_raise_model_http(exc)
|
|
358
395
|
except HTTPException:
|
|
359
396
|
raise
|
|
360
397
|
except Exception as exc:
|
|
@@ -367,16 +404,17 @@ def create_models_router(
|
|
|
367
404
|
|
|
368
405
|
@router.post("/engines/prepare-model/stream")
|
|
369
406
|
async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
|
|
370
|
-
|
|
407
|
+
current_user = _authorize_model_admin(request, req.user_email)
|
|
408
|
+
effective_email = _effective_email(current_user, req.user_email)
|
|
371
409
|
|
|
372
410
|
async def event_stream():
|
|
373
411
|
try:
|
|
374
412
|
async for chunk in prepare_and_load_model_stream(
|
|
375
|
-
req.model, request, engine=req.engine, user_email=
|
|
413
|
+
req.model, request, engine=req.engine, user_email=effective_email,
|
|
376
414
|
allow_download=req.allow_download,
|
|
377
415
|
):
|
|
378
416
|
yield chunk
|
|
379
|
-
except HTTPException as exc:
|
|
417
|
+
except (HTTPException, ModelRuntimeError) as exc:
|
|
380
418
|
yield sse_event("error", {
|
|
381
419
|
"status_code": exc.status_code,
|
|
382
420
|
"detail": exc.detail or "모델 준비에 실패했습니다.",
|
|
@@ -404,14 +442,10 @@ def create_models_router(
|
|
|
404
442
|
raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
|
|
405
443
|
if not req.key.strip():
|
|
406
444
|
raise HTTPException(status_code=400, detail="API 키가 비어있습니다.")
|
|
407
|
-
current_user =
|
|
408
|
-
if REQUIRE_AUTH and
|
|
409
|
-
raise HTTPException(status_code=
|
|
410
|
-
|
|
411
|
-
users = load_users()
|
|
412
|
-
if get_user_role(current_user or "", users) != "admin":
|
|
413
|
-
raise HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
|
|
414
|
-
target_email = (req.user_email or current_user or "").strip()
|
|
445
|
+
current_user = require_user(request)
|
|
446
|
+
if REQUIRE_AUTH and req.user_email and _normalized_identity(req.user_email) != _normalized_identity(current_user):
|
|
447
|
+
raise HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
|
|
448
|
+
target_email = str(_effective_email(current_user, req.user_email) or "").strip()
|
|
415
449
|
if not target_email:
|
|
416
450
|
raise HTTPException(status_code=400, detail="사용자 식별이 필요합니다. 로그인 후 다시 시도하세요.")
|
|
417
451
|
set_user_api_key(target_email, req.provider, req.key.strip())
|
|
@@ -420,7 +454,8 @@ def create_models_router(
|
|
|
420
454
|
# ── Models ────────────────────────────────────────────────────────────
|
|
421
455
|
|
|
422
456
|
@router.get("/models")
|
|
423
|
-
async def list_models():
|
|
457
|
+
async def list_models(request: Request):
|
|
458
|
+
_authorize_model_admin(request)
|
|
424
459
|
engines = await asyncio.to_thread(engine_status)
|
|
425
460
|
recommended = _recommended_with_engine_options(
|
|
426
461
|
list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", []))),
|
|
@@ -457,6 +492,7 @@ def create_models_router(
|
|
|
457
492
|
|
|
458
493
|
@router.post("/models/load")
|
|
459
494
|
async def load_model(req: LoadModelRequest, request: Request):
|
|
495
|
+
current_user = _authorize_model_admin(request, req.user_email)
|
|
460
496
|
try:
|
|
461
497
|
from latticeai.core.model_compat import friendly_model_runtime_error, model_runtime_compatibility
|
|
462
498
|
|
|
@@ -471,10 +507,13 @@ def create_models_router(
|
|
|
471
507
|
if compatibility.get("supported") is False:
|
|
472
508
|
raise HTTPException(status_code=400, detail=compatibility)
|
|
473
509
|
return await prepare_and_load_model(
|
|
474
|
-
model_id, request, engine=req.engine,
|
|
510
|
+
model_id, request, engine=req.engine,
|
|
511
|
+
user_email=_effective_email(current_user, req.user_email),
|
|
475
512
|
adapter_path=req.adapter_path, draft_model_id=req.draft_model_id,
|
|
476
513
|
allow_download=req.allow_download,
|
|
477
514
|
)
|
|
515
|
+
except ModelRuntimeError as exc:
|
|
516
|
+
_raise_model_http(exc)
|
|
478
517
|
except HTTPException:
|
|
479
518
|
raise
|
|
480
519
|
except Exception as e:
|
|
@@ -485,7 +524,7 @@ def create_models_router(
|
|
|
485
524
|
|
|
486
525
|
@router.post("/models/switch/{model_id:path}")
|
|
487
526
|
async def switch_model(model_id: str, request: Request):
|
|
488
|
-
|
|
527
|
+
_authorize_model_admin(request)
|
|
489
528
|
try:
|
|
490
529
|
_router.switch_model(model_id)
|
|
491
530
|
return {"status": "ok", "current": _router.current_model_id}
|
|
@@ -494,13 +533,13 @@ def create_models_router(
|
|
|
494
533
|
|
|
495
534
|
@router.delete("/models/unload/{model_id:path}")
|
|
496
535
|
async def unload_model(model_id: str, request: Request):
|
|
497
|
-
|
|
536
|
+
_authorize_model_admin(request)
|
|
498
537
|
_router.unload_model(model_id)
|
|
499
538
|
return {"status": "ok", "unloaded": model_id}
|
|
500
539
|
|
|
501
540
|
@router.delete("/models/unload-all")
|
|
502
541
|
async def unload_all_models(request: Request):
|
|
503
|
-
|
|
542
|
+
_authorize_model_admin(request)
|
|
504
543
|
unloaded = _router.loaded_model_ids
|
|
505
544
|
_router.unload_all()
|
|
506
545
|
return {"status": "ok", "unloaded": unloaded}
|
|
@@ -513,7 +552,7 @@ def create_models_router(
|
|
|
513
552
|
hardware, load_strategy, license, safety_notes) from the structured registry.
|
|
514
553
|
"""
|
|
515
554
|
require_user(request)
|
|
516
|
-
from auto_setup import probe as auto_setup_probe
|
|
555
|
+
from latticeai.setup.auto_setup import probe as auto_setup_probe
|
|
517
556
|
from latticeai.services.model_recommendation import recommend_catalog
|
|
518
557
|
|
|
519
558
|
profile = await asyncio.to_thread(lambda: auto_setup_probe().to_json())
|
package/latticeai/api/network.py
CHANGED
|
@@ -23,7 +23,7 @@ class PeerPushRequest(BaseModel):
|
|
|
23
23
|
workspace_id: Optional[str] = None
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def create_network_router(*, network, identity, require_user) -> APIRouter:
|
|
26
|
+
def create_network_router(*, network, identity, require_user, require_admin) -> APIRouter:
|
|
27
27
|
router = APIRouter()
|
|
28
28
|
|
|
29
29
|
@router.get("/network/identity")
|
|
@@ -33,12 +33,12 @@ def create_network_router(*, network, identity, require_user) -> APIRouter:
|
|
|
33
33
|
|
|
34
34
|
@router.get("/network/peers")
|
|
35
35
|
async def network_peers(request: Request):
|
|
36
|
-
|
|
36
|
+
require_admin(request)
|
|
37
37
|
return {"peers": network.list_peers()}
|
|
38
38
|
|
|
39
39
|
@router.post("/network/peers")
|
|
40
40
|
async def network_pair(req: PeerPairRequest, request: Request):
|
|
41
|
-
|
|
41
|
+
require_admin(request)
|
|
42
42
|
try:
|
|
43
43
|
return {"status": "paired", "peer": network.add_peer(
|
|
44
44
|
name=req.name, base_url=req.base_url, public_key=req.public_key,
|
|
@@ -48,7 +48,7 @@ def create_network_router(*, network, identity, require_user) -> APIRouter:
|
|
|
48
48
|
|
|
49
49
|
@router.delete("/network/peers/{peer_id}")
|
|
50
50
|
async def network_unpair(peer_id: str, request: Request):
|
|
51
|
-
|
|
51
|
+
require_admin(request)
|
|
52
52
|
try:
|
|
53
53
|
return network.remove_peer(peer_id)
|
|
54
54
|
except FileNotFoundError as exc:
|
|
@@ -56,7 +56,7 @@ def create_network_router(*, network, identity, require_user) -> APIRouter:
|
|
|
56
56
|
|
|
57
57
|
@router.post("/network/push/{peer_id}")
|
|
58
58
|
async def network_push(peer_id: str, req: PeerPushRequest, request: Request):
|
|
59
|
-
|
|
59
|
+
require_admin(request)
|
|
60
60
|
try:
|
|
61
61
|
return network.push_to_peer(peer_id, workspace_id=req.workspace_id)
|
|
62
62
|
except FileNotFoundError as exc:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
+
import os
|
|
8
9
|
import secrets
|
|
9
10
|
import threading
|
|
10
11
|
import time
|
|
@@ -14,6 +15,8 @@ from typing import Dict, Optional, Tuple
|
|
|
14
15
|
|
|
15
16
|
from fastapi import APIRouter, HTTPException, Request
|
|
16
17
|
|
|
18
|
+
from latticeai.core.io_utils import atomic_write_json
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
_PERMISSION_ACTION_LABELS = {
|
|
19
22
|
"list": "폴더 목록 보기",
|
|
@@ -37,6 +40,12 @@ class PermissionGateway:
|
|
|
37
40
|
self.discord_permission_channel = config.discord_permission_channel
|
|
38
41
|
self.permission_monitor_secret = config.permission_monitor_secret
|
|
39
42
|
self.perm_queue_file = data_dir / "permission_queue.json"
|
|
43
|
+
explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
|
|
44
|
+
public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
|
|
45
|
+
default_ui_url = f"http://127.0.0.1:{getattr(config, 'port', 4825)}/app#/admin/permissions"
|
|
46
|
+
self.permission_ui_url = explicit_ui_url or (
|
|
47
|
+
f"{public_url}/app#/admin/permissions" if public_url else default_ui_url
|
|
48
|
+
)
|
|
40
49
|
|
|
41
50
|
@staticmethod
|
|
42
51
|
def token_hash(token: str) -> str:
|
|
@@ -56,10 +65,9 @@ class PermissionGateway:
|
|
|
56
65
|
return {}
|
|
57
66
|
|
|
58
67
|
def _write_queue(self, queue: Dict) -> None:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
tmp.replace(self.perm_queue_file)
|
|
68
|
+
# Approval metadata contains local paths and identities. Keep writes
|
|
69
|
+
# atomic and private even when the caller's umask is permissive.
|
|
70
|
+
atomic_write_json(self.perm_queue_file, queue)
|
|
63
71
|
|
|
64
72
|
def _perm_queue_write(self, token: str, record: Dict[str, object]) -> None:
|
|
65
73
|
try:
|
|
@@ -72,8 +80,10 @@ class PermissionGateway:
|
|
|
72
80
|
logging.warning("perm_queue_write failed: %s", exc)
|
|
73
81
|
|
|
74
82
|
def _perm_queue_remove(self, token: str) -> None:
|
|
83
|
+
self._perm_queue_remove_key(self.token_hash(token))
|
|
84
|
+
|
|
85
|
+
def _perm_queue_remove_key(self, key: str) -> None:
|
|
75
86
|
try:
|
|
76
|
-
key = self.token_hash(token)
|
|
77
87
|
with self.perm_queue_lock:
|
|
78
88
|
queue = self._read_queue()
|
|
79
89
|
queue.pop(key, None)
|
|
@@ -81,6 +91,27 @@ class PermissionGateway:
|
|
|
81
91
|
except Exception as exc:
|
|
82
92
|
logging.warning("perm_queue_remove failed: %s", exc)
|
|
83
93
|
|
|
94
|
+
def resolve_approval_key(self, token_or_hint: str) -> str:
|
|
95
|
+
"""Resolve either the private token or its UI-safe eight-char hint.
|
|
96
|
+
|
|
97
|
+
Only an authenticated administrator/monitor reaches approval routes.
|
|
98
|
+
Supporting a unique hint lets the admin UI act on pending requests
|
|
99
|
+
without ever receiving the bearer-grade full token.
|
|
100
|
+
"""
|
|
101
|
+
value = str(token_or_hint or "")
|
|
102
|
+
direct_key = self.token_hash(value)
|
|
103
|
+
with self.local_approval_lock:
|
|
104
|
+
if direct_key in self.local_approvals:
|
|
105
|
+
return direct_key
|
|
106
|
+
matches = [
|
|
107
|
+
key
|
|
108
|
+
for key, record in self.local_approvals.items()
|
|
109
|
+
if len(value) == 8 and record.get("token_hint") == value
|
|
110
|
+
]
|
|
111
|
+
if len(matches) > 1:
|
|
112
|
+
raise HTTPException(status_code=409, detail="요청 ID가 중복되었습니다. 관리자 목록을 새로고침하세요.")
|
|
113
|
+
return matches[0] if matches else direct_key
|
|
114
|
+
|
|
84
115
|
@staticmethod
|
|
85
116
|
def normalize_local_path_for_approval(path: str) -> str:
|
|
86
117
|
return str(Path(path).expanduser().resolve())
|
|
@@ -91,6 +122,7 @@ class PermissionGateway:
|
|
|
91
122
|
|
|
92
123
|
def _notify_discord_permission_sync(self, token: str, path: str, action: str, user_email: str) -> None:
|
|
93
124
|
sent = False
|
|
125
|
+
token_hint = self.token_hint(token)
|
|
94
126
|
if self.discord_bot_token and self.discord_permission_channel:
|
|
95
127
|
action_label = _PERMISSION_ACTION_LABELS.get(action, action)
|
|
96
128
|
expires_at_iso = time.strftime(
|
|
@@ -102,9 +134,9 @@ class PermissionGateway:
|
|
|
102
134
|
f"**경로:** `{path}`\n"
|
|
103
135
|
f"**작업:** {action_label}\n"
|
|
104
136
|
f"**요청자:** {user_email}\n"
|
|
105
|
-
f"
|
|
137
|
+
f"**요청 ID:** `{token_hint}`\n"
|
|
106
138
|
f"**만료:** {expires_at_iso}\n\n"
|
|
107
|
-
f"
|
|
139
|
+
f"승인/거부: {self.permission_ui_url}"
|
|
108
140
|
)
|
|
109
141
|
payload = json.dumps({"content": msg}, ensure_ascii=False).encode("utf-8")
|
|
110
142
|
try:
|
|
@@ -139,15 +171,11 @@ class PermissionGateway:
|
|
|
139
171
|
{"name": "경로", "value": f"`{path}`", "inline": False},
|
|
140
172
|
{"name": "작업", "value": action_label, "inline": True},
|
|
141
173
|
{"name": "요청자", "value": user_email, "inline": True},
|
|
142
|
-
{"name": "
|
|
174
|
+
{"name": "요청 ID", "value": f"`{token_hint}`", "inline": False},
|
|
143
175
|
{"name": "만료", "value": expires_at_iso, "inline": True},
|
|
144
176
|
],
|
|
145
177
|
"footer": {
|
|
146
|
-
"text":
|
|
147
|
-
"승인: POST /permissions/approve/{token} | "
|
|
148
|
-
"거부: POST /permissions/deny/{token} | "
|
|
149
|
-
"목록: GET /permissions/pending"
|
|
150
|
-
)
|
|
178
|
+
"text": f"승인/거부 UI: {self.permission_ui_url}"
|
|
151
179
|
},
|
|
152
180
|
}
|
|
153
181
|
]
|
|
@@ -216,9 +244,9 @@ class PermissionGateway:
|
|
|
216
244
|
now = time.time()
|
|
217
245
|
key = self.token_hash(token)
|
|
218
246
|
with self.local_approval_lock:
|
|
219
|
-
expired = [
|
|
220
|
-
for
|
|
221
|
-
self.local_approvals.pop(
|
|
247
|
+
expired = [approval_key for approval_key, value in self.local_approvals.items() if float(value.get("expires_at", 0)) < now]
|
|
248
|
+
for expired_key in expired:
|
|
249
|
+
self.local_approvals.pop(expired_key, None)
|
|
222
250
|
record = self.local_approvals.get(key)
|
|
223
251
|
if not record:
|
|
224
252
|
raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
|
|
@@ -232,19 +260,31 @@ class PermissionGateway:
|
|
|
232
260
|
raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
|
|
233
261
|
|
|
234
262
|
def check_permission_auth(self, request: Request, token: Optional[str] = None) -> None:
|
|
263
|
+
"""Authorize an approval decision.
|
|
264
|
+
|
|
265
|
+
Possession of the approval token is deliberately *not* sufficient:
|
|
266
|
+
the requester receives that token in order to poll status, so allowing
|
|
267
|
+
token ownership here would let them approve their own request.
|
|
268
|
+
"""
|
|
269
|
+
_ = token # retained for compatibility with existing call sites
|
|
235
270
|
if self.permission_monitor_secret:
|
|
236
271
|
auth_header = request.headers.get("Authorization", "")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if token:
|
|
240
|
-
key = self.token_hash(token)
|
|
241
|
-
current_user = self.get_current_user(request)
|
|
242
|
-
with self.local_approval_lock:
|
|
243
|
-
record = self.local_approvals.get(key)
|
|
244
|
-
if current_user and record and record.get("user_email") == current_user:
|
|
272
|
+
expected = f"Bearer {self.permission_monitor_secret}"
|
|
273
|
+
if secrets.compare_digest(auth_header, expected):
|
|
245
274
|
return
|
|
246
275
|
self.require_admin(request)
|
|
247
276
|
|
|
277
|
+
def require_permission_status_owner(self, *, token: str, user_email: str) -> None:
|
|
278
|
+
"""Allow approval-status polling only to the original requester."""
|
|
279
|
+
key = self.token_hash(token)
|
|
280
|
+
with self.local_approval_lock:
|
|
281
|
+
record = self.local_approvals.get(key)
|
|
282
|
+
# Missing/expired tokens intentionally retain the generic status
|
|
283
|
+
# response. For a live request, however, a different account must not
|
|
284
|
+
# learn or poll its state even if it obtains the URL.
|
|
285
|
+
if record and record.get("user_email") != user_email:
|
|
286
|
+
raise HTTPException(status_code=403, detail="다른 사용자의 승인 상태는 조회할 수 없습니다.")
|
|
287
|
+
|
|
248
288
|
def ensure_path_allowed(self, path: str, *, action: str) -> None:
|
|
249
289
|
if action != "write":
|
|
250
290
|
return
|
|
@@ -296,7 +336,7 @@ def create_permissions_router(
|
|
|
296
336
|
@router.post("/permissions/approve/{token}")
|
|
297
337
|
async def permissions_approve(token: str, request: Request):
|
|
298
338
|
gateway.check_permission_auth(request, token)
|
|
299
|
-
key = gateway.
|
|
339
|
+
key = gateway.resolve_approval_key(token)
|
|
300
340
|
with gateway.local_approval_lock:
|
|
301
341
|
record = gateway.local_approvals.get(key)
|
|
302
342
|
if not record:
|
|
@@ -305,7 +345,7 @@ def create_permissions_router(
|
|
|
305
345
|
gateway.local_approvals.pop(key, None)
|
|
306
346
|
raise HTTPException(status_code=410, detail="토큰이 만료되었습니다.")
|
|
307
347
|
record["approved"] = True
|
|
308
|
-
gateway.
|
|
348
|
+
gateway._perm_queue_remove_key(key)
|
|
309
349
|
logging.info(
|
|
310
350
|
"Permission approved: token=%s path=%s action=%s user=%s",
|
|
311
351
|
gateway.token_hint(token),
|
|
@@ -324,10 +364,10 @@ def create_permissions_router(
|
|
|
324
364
|
@router.post("/permissions/deny/{token}")
|
|
325
365
|
async def permissions_deny(token: str, request: Request):
|
|
326
366
|
gateway.check_permission_auth(request, token)
|
|
327
|
-
key = gateway.
|
|
367
|
+
key = gateway.resolve_approval_key(token)
|
|
328
368
|
with gateway.local_approval_lock:
|
|
329
369
|
record = gateway.local_approvals.pop(key, None)
|
|
330
|
-
gateway.
|
|
370
|
+
gateway._perm_queue_remove_key(key)
|
|
331
371
|
if not record:
|
|
332
372
|
raise HTTPException(status_code=404, detail="토큰이 없거나 이미 처리되었습니다.")
|
|
333
373
|
logging.info(
|
|
@@ -347,7 +387,8 @@ def create_permissions_router(
|
|
|
347
387
|
|
|
348
388
|
@router.get("/permissions/status/{token}")
|
|
349
389
|
async def permissions_status(token: str, request: Request):
|
|
350
|
-
require_user(request)
|
|
390
|
+
current_user = require_user(request)
|
|
391
|
+
gateway.require_permission_status_owner(token=token, user_email=current_user)
|
|
351
392
|
now = time.time()
|
|
352
393
|
key = gateway.token_hash(token)
|
|
353
394
|
with gateway.local_approval_lock:
|
package/latticeai/api/plugins.py
CHANGED
|
@@ -40,8 +40,11 @@ def create_plugins_router(
|
|
|
40
40
|
require_user: Callable[[Request], str],
|
|
41
41
|
require_admin: Callable[[Request], Any],
|
|
42
42
|
append_audit_event: Callable[..., None],
|
|
43
|
+
gate_write: Optional[Callable[[Request], Optional[str]]] = None,
|
|
43
44
|
register_skill: Optional[Callable[[str, str], Any]] = None,
|
|
44
|
-
plugin_runners_factory: Optional[
|
|
45
|
+
plugin_runners_factory: Optional[
|
|
46
|
+
Callable[[str, Optional[str]], Dict[str, Callable[..., Any]]]
|
|
47
|
+
] = None,
|
|
45
48
|
ui_file_response: Optional[Callable[[Path], Any]] = None,
|
|
46
49
|
static_dir: Optional[Path] = None,
|
|
47
50
|
) -> APIRouter:
|
|
@@ -93,19 +96,30 @@ def create_plugins_router(
|
|
|
93
96
|
|
|
94
97
|
@router.post("/plugins/enable")
|
|
95
98
|
async def plugin_enable(req: PluginActionRequest, request: Request):
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
admin_email, _ = require_admin(request)
|
|
100
|
+
plugin = registry.set_enabled(req.plugin_id, True)
|
|
101
|
+
append_audit_event("plugin_enable", user_email=admin_email, plugin=req.plugin_id)
|
|
102
|
+
return {"plugin": plugin}
|
|
98
103
|
|
|
99
104
|
@router.post("/plugins/disable")
|
|
100
105
|
async def plugin_disable(req: PluginActionRequest, request: Request):
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
admin_email, _ = require_admin(request)
|
|
107
|
+
plugin = registry.set_enabled(req.plugin_id, False)
|
|
108
|
+
append_audit_event("plugin_disable", user_email=admin_email, plugin=req.plugin_id)
|
|
109
|
+
return {"plugin": plugin}
|
|
103
110
|
|
|
104
111
|
@router.post("/plugins/execute")
|
|
105
112
|
async def plugin_execute(req: PluginExecuteRequest, request: Request):
|
|
106
113
|
current_user = require_user(request)
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
scope = gate_write(request) if gate_write is not None else None
|
|
115
|
+
runners = plugin_runners_factory(current_user, scope) if plugin_runners_factory else {}
|
|
116
|
+
result = registry.execute_action(
|
|
117
|
+
req.plugin_id,
|
|
118
|
+
req.action,
|
|
119
|
+
req.args,
|
|
120
|
+
runners=runners,
|
|
121
|
+
workspace_id=scope,
|
|
122
|
+
)
|
|
109
123
|
append_audit_event("plugin_execute", user_email=current_user, plugin=req.plugin_id, action=req.action, status=result.status)
|
|
110
124
|
return result.as_dict()
|
|
111
125
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Knowledge Graph portability routes — local export / import / backup / restore.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Status reads require a signed-in user. Whole-graph exports, provenance, and all
|
|
4
|
+
mutating operations require admin because the graph is machine-global, not
|
|
5
|
+
workspace-scoped. Nothing here touches a cloud service.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -97,14 +97,14 @@ def create_portability_router(
|
|
|
97
97
|
@router.get("/api/knowledge-graph/provenance")
|
|
98
98
|
async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
|
|
99
99
|
"""Recent ingestions (provenance trail) for the ingestion-sources UI."""
|
|
100
|
-
|
|
100
|
+
require_admin(request)
|
|
101
101
|
_require_service()
|
|
102
102
|
return service.recent_ingestions(limit=limit, source_type=source_type)
|
|
103
103
|
|
|
104
104
|
@router.post("/api/knowledge-graph/export")
|
|
105
105
|
async def export_graph(request: Request):
|
|
106
106
|
"""Logical JSON export of the whole graph (read-only)."""
|
|
107
|
-
|
|
107
|
+
require_admin(request)
|
|
108
108
|
_require_service()
|
|
109
109
|
return service.export()
|
|
110
110
|
|
|
@@ -12,7 +12,7 @@ import secrets
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Callable, Optional, Set
|
|
14
14
|
|
|
15
|
-
from fastapi import APIRouter, Request
|
|
15
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
16
16
|
from fastapi.responses import StreamingResponse
|
|
17
17
|
from pydantic import BaseModel
|
|
18
18
|
|
|
@@ -48,8 +48,21 @@ def create_realtime_router(
|
|
|
48
48
|
sub_id = secrets.token_urlsafe(12)
|
|
49
49
|
sub = bus.add_subscriber(sub_id, workspace_scope=scope, user=user or None)
|
|
50
50
|
|
|
51
|
+
def refresh_authorization(current_sub) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
refreshed_user = require_user(request)
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
if refreshed_user != user:
|
|
57
|
+
return False
|
|
58
|
+
current_sub.workspace_scope = allowed_scopes(refreshed_user or None)
|
|
59
|
+
return True
|
|
60
|
+
|
|
51
61
|
async def event_gen():
|
|
52
|
-
async for frame in bus.stream(
|
|
62
|
+
async for frame in bus.stream(
|
|
63
|
+
sub,
|
|
64
|
+
refresh_authorization=refresh_authorization,
|
|
65
|
+
):
|
|
53
66
|
if await request.is_disconnected():
|
|
54
67
|
break
|
|
55
68
|
yield frame
|
|
@@ -76,15 +89,30 @@ def create_realtime_router(
|
|
|
76
89
|
@router.post("/realtime/presence/join")
|
|
77
90
|
async def realtime_join(req: PresenceRequest, request: Request):
|
|
78
91
|
user = require_user(request)
|
|
92
|
+
scope = allowed_scopes(user or None)
|
|
93
|
+
workspace_id = req.workspace_id
|
|
94
|
+
if scope is not None:
|
|
95
|
+
if workspace_id is None:
|
|
96
|
+
if not scope:
|
|
97
|
+
raise HTTPException(status_code=403, detail="No accessible workspace for presence.")
|
|
98
|
+
workspace_id = "personal" if "personal" in scope else sorted(scope)[0]
|
|
99
|
+
elif workspace_id not in scope:
|
|
100
|
+
raise HTTPException(status_code=403, detail="Workspace presence access denied.")
|
|
79
101
|
client_id = req.client_id or secrets.token_urlsafe(8)
|
|
80
|
-
|
|
102
|
+
try:
|
|
103
|
+
record = bus.join(client_id, user=user or None, workspace_id=workspace_id)
|
|
104
|
+
except PermissionError as exc:
|
|
105
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
81
106
|
return {"presence": record}
|
|
82
107
|
|
|
83
108
|
@router.post("/realtime/presence/leave")
|
|
84
109
|
async def realtime_leave(req: PresenceRequest, request: Request):
|
|
85
|
-
require_user(request)
|
|
110
|
+
user = require_user(request)
|
|
86
111
|
if req.client_id:
|
|
87
|
-
|
|
112
|
+
try:
|
|
113
|
+
bus.leave(req.client_id, user=user or None)
|
|
114
|
+
except PermissionError as exc:
|
|
115
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
88
116
|
return {"status": "ok"}
|
|
89
117
|
|
|
90
118
|
return router
|
package/latticeai/api/setup.py
CHANGED
|
@@ -9,7 +9,7 @@ from fastapi.responses import StreamingResponse
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
11
|
from latticeai.services.process_audit import command_plan
|
|
12
|
-
from auto_setup import (
|
|
12
|
+
from latticeai.setup.auto_setup import (
|
|
13
13
|
plan as auto_setup_plan,
|
|
14
14
|
preset as auto_setup_preset,
|
|
15
15
|
probe as auto_setup_probe,
|
|
@@ -17,7 +17,7 @@ from auto_setup import (
|
|
|
17
17
|
verify as auto_setup_verify,
|
|
18
18
|
)
|
|
19
19
|
from latticeai.models.router import parse_model_ref
|
|
20
|
-
from
|
|
20
|
+
from latticeai.setup.wizard import get_recommendations, install_stream, open_url, scan_environment
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class SetupInstallRequest(BaseModel):
|