ltcai 6.0.0 → 6.2.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 -35
- package/docs/CHANGELOG.md +68 -0
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +1 -1
- package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +1 -1
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +1 -1
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +1 -1
- package/frontend/src/App.tsx +3 -1281
- package/frontend/src/components/LanguageSwitcher.tsx +23 -0
- package/frontend/src/components/ProductFlow.tsx +32 -662
- package/frontend/src/components/onboarding/ProductFlowScreens.tsx +688 -0
- package/frontend/src/features/admin/AdminConsole.tsx +294 -0
- package/frontend/src/features/brain/BrainHome.tsx +999 -0
- package/frontend/src/features/brain/brainData.ts +98 -0
- package/frontend/src/features/brain/graphLayout.ts +26 -0
- package/frontend/src/features/brain/types.ts +44 -0
- package/frontend/src/features/review/ReviewCard.tsx +15 -10
- package/frontend/src/i18n.ts +208 -0
- package/frontend/src/styles.css +240 -0
- 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 +52 -33
- package/latticeai/api/tools.py +50 -23
- package/latticeai/app_factory.py +65 -47
- package/latticeai/cli/__init__.py +1 -0
- package/latticeai/cli/entrypoint.py +283 -0
- package/latticeai/cli/runtime.py +37 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/__init__.py +0 -0
- package/latticeai/integrations/telegram_bot.py +1009 -0
- package/latticeai/runtime/lifespan_runtime.py +1 -1
- package/latticeai/runtime/platform_runtime_wiring.py +87 -0
- package/latticeai/runtime/router_registration.py +49 -30
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/p_reinforce.py +258 -0
- package/latticeai/services/router_context.py +52 -0
- package/latticeai/services/tool_dispatch.py +82 -25
- package/ltcai_cli.py +7 -305
- package/p_reinforce.py +4 -255
- package/package.json +2 -1
- package/scripts/release_smoke.py +133 -0
- package/scripts/wheel_smoke.py +4 -0
- 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 +5 -5
- package/static/app/assets/{index-xRn29gI8.css → index-B2-1Gm0q.css} +1 -1
- package/static/app/assets/index-D91Rz5--.js +16 -0
- package/static/app/assets/index-D91Rz5--.js.map +1 -0
- package/static/app/index.html +2 -2
- package/telegram_bot.py +9 -1008
- package/static/app/assets/index-D2zafMYb.js +0 -16
- package/static/app/assets/index-D2zafMYb.js.map +0 -1
package/latticeai/app_factory.py
CHANGED
|
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|
|
17
17
|
import threading
|
|
18
18
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
19
19
|
|
|
20
|
-
from latticeai.runtime.automation_runtime import build_automation_runtime
|
|
21
20
|
from latticeai.runtime.app_context_runtime import build_app_context
|
|
22
21
|
from latticeai.runtime.bootstrap import build_session_runtime
|
|
23
22
|
from latticeai.runtime.brain_runtime import build_brain_runtime
|
|
@@ -33,6 +32,7 @@ from latticeai.runtime.platform_services_runtime import (
|
|
|
33
32
|
build_brain_network,
|
|
34
33
|
build_model_service,
|
|
35
34
|
)
|
|
35
|
+
from latticeai.runtime.platform_runtime_wiring import build_platform_automation_runtime
|
|
36
36
|
from latticeai.runtime.persistence_runtime import build_persistence_runtime
|
|
37
37
|
from latticeai.runtime.router_registration import (
|
|
38
38
|
build_auth_admin_security_router_bundle,
|
|
@@ -45,6 +45,7 @@ from latticeai.runtime.router_registration import (
|
|
|
45
45
|
)
|
|
46
46
|
from latticeai.runtime.security_runtime import build_security_runtime
|
|
47
47
|
from latticeai.runtime.web_runtime import build_web_runtime
|
|
48
|
+
from latticeai.services.router_context import InteractionRouterContext, ToolRouterContext
|
|
48
49
|
|
|
49
50
|
if TYPE_CHECKING: # imports for annotations only — keep module import light
|
|
50
51
|
from fastapi import FastAPI
|
|
@@ -145,7 +146,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
145
146
|
from latticeai.api.workspace import create_workspace_router, _workspace_scope_from_request
|
|
146
147
|
from latticeai.api.health import create_health_router
|
|
147
148
|
# ── v2 Agentic Workspace Platform layers ─────────────────────────────────────
|
|
148
|
-
from latticeai.services.platform_runtime import PlatformRuntime
|
|
149
149
|
from latticeai.api.plugins import create_plugins_router
|
|
150
150
|
from latticeai.api.workflow_designer import create_workflow_designer_router
|
|
151
151
|
from latticeai.api.agents import create_agents_router
|
|
@@ -153,7 +153,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
153
153
|
from latticeai.api.invitations import create_invitations_router
|
|
154
154
|
from latticeai.api.marketplace import create_marketplace_router
|
|
155
155
|
from latticeai.api.models import create_models_router
|
|
156
|
-
from latticeai.api.chat import create_chat_router
|
|
156
|
+
from latticeai.api.chat import build_recent_chat_context, create_chat_router
|
|
157
157
|
from latticeai.api.search import create_search_router
|
|
158
158
|
from latticeai.api.tools import create_tools_router
|
|
159
159
|
from latticeai.api.static_routes import create_static_routes_router
|
|
@@ -181,6 +181,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
181
181
|
configure_tool_dispatch,
|
|
182
182
|
get_tool_permission,
|
|
183
183
|
list_tool_permissions,
|
|
184
|
+
build_agent_runtime,
|
|
184
185
|
tool_response as _tool_response,
|
|
185
186
|
)
|
|
186
187
|
from latticeai.core.tool_registry import TOOL_CATALOG_BRIEF as _TOOL_CATALOG_BRIEF # noqa: F401
|
|
@@ -188,9 +189,9 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
188
189
|
_get_combined_registry,
|
|
189
190
|
_fetch_skills_marketplace, install_skill, SKILLS_DIR,
|
|
190
191
|
)
|
|
191
|
-
from p_reinforce import PReinforceGardener
|
|
192
|
+
from latticeai.services.p_reinforce import PReinforceGardener
|
|
192
193
|
from setup_wizard import get_recommendations, scan_environment
|
|
193
|
-
from tools import ensure_agent_root
|
|
194
|
+
from tools import ensure_agent_root, execute_tool, knowledge_save
|
|
194
195
|
|
|
195
196
|
try:
|
|
196
197
|
import keyring
|
|
@@ -1238,10 +1239,35 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1238
1239
|
on_chat_message = None
|
|
1239
1240
|
if ENABLE_TELEGRAM:
|
|
1240
1241
|
def _telegram_chat_mirror(role: str, text: str, source: Optional[str] = None) -> None:
|
|
1241
|
-
from telegram_bot import broadcast_web_chat
|
|
1242
|
+
from latticeai.integrations.telegram_bot import broadcast_web_chat
|
|
1242
1243
|
_spawn(broadcast_web_chat(role, text), name="telegram_broadcast")
|
|
1243
1244
|
on_chat_message = _telegram_chat_mirror
|
|
1244
1245
|
|
|
1246
|
+
def _recent_chat_context(
|
|
1247
|
+
limit: int = 10,
|
|
1248
|
+
include_image_missing_replies: bool = True,
|
|
1249
|
+
user_email: Optional[str] = None,
|
|
1250
|
+
conversation_id: Optional[str] = None,
|
|
1251
|
+
) -> str:
|
|
1252
|
+
return build_recent_chat_context(
|
|
1253
|
+
get_history=get_history,
|
|
1254
|
+
limit=limit,
|
|
1255
|
+
include_image_missing_replies=include_image_missing_replies,
|
|
1256
|
+
user_email=user_email,
|
|
1257
|
+
conversation_id=conversation_id,
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
CHAT_AGENT_RUNTIME = build_agent_runtime(
|
|
1261
|
+
model_router=router,
|
|
1262
|
+
execute_tool=execute_tool,
|
|
1263
|
+
recent_chat_context=_recent_chat_context,
|
|
1264
|
+
clear_history=clear_history,
|
|
1265
|
+
knowledge_save=knowledge_save,
|
|
1266
|
+
audit=append_audit_event,
|
|
1267
|
+
hooks=HOOKS_REGISTRY,
|
|
1268
|
+
brain_memory=BRAIN_MEMORY,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1245
1271
|
# ── Typed dependency context (latticeai.services.app_context) ────────────────
|
|
1246
1272
|
# One context object replaces the historical 25-30-kwarg router wiring.
|
|
1247
1273
|
context = build_app_context(
|
|
@@ -1258,6 +1284,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1258
1284
|
chat_service=CHAT_SERVICE,
|
|
1259
1285
|
context_assembler=CONTEXT_ASSEMBLER,
|
|
1260
1286
|
brain_memory=BRAIN_MEMORY,
|
|
1287
|
+
chat_agent_runtime=CHAT_AGENT_RUNTIME,
|
|
1261
1288
|
gardener=gardener,
|
|
1262
1289
|
hooks=HOOKS_REGISTRY,
|
|
1263
1290
|
realtime_bus=REALTIME_BUS,
|
|
@@ -1311,20 +1338,9 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1311
1338
|
|
|
1312
1339
|
|
|
1313
1340
|
# ── v2 Agentic Workspace Platform: cross-system wiring ───────────────────────
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
# Synchronous model bridge for the orchestrator's role runner. Safe
|
|
1318
|
-
# because the agents run endpoint executes start() in a worker thread
|
|
1319
|
-
# (asyncio.to_thread), where no event loop is running.
|
|
1320
|
-
import asyncio as _asyncio
|
|
1321
|
-
|
|
1322
|
-
return str(_asyncio.run(router.generate(
|
|
1323
|
-
message, context=context, max_tokens=max_tokens, temperature=temperature,
|
|
1324
|
-
)))
|
|
1325
|
-
|
|
1326
|
-
PLATFORM = PlatformRuntime(
|
|
1327
|
-
store=WORKSPACE_OS,
|
|
1341
|
+
_platform_automation_runtime = build_platform_automation_runtime(
|
|
1342
|
+
model_router=router,
|
|
1343
|
+
workspace_store=WORKSPACE_OS,
|
|
1328
1344
|
workspace_service=WORKSPACE_SERVICE,
|
|
1329
1345
|
plugin_registry=PLUGIN_REGISTRY,
|
|
1330
1346
|
get_current_user=get_current_user,
|
|
@@ -1332,23 +1348,17 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1332
1348
|
workspace_scope_from_request=_workspace_scope_from_request,
|
|
1333
1349
|
get_tool_permission=get_tool_permission,
|
|
1334
1350
|
hooks=HOOKS_REGISTRY,
|
|
1335
|
-
llm_generate=_llm_generate_sync,
|
|
1336
|
-
llm_available=lambda: bool(getattr(router, "current_model_id", None)),
|
|
1337
1351
|
agent_registry=AGENT_REGISTRY,
|
|
1338
|
-
)
|
|
1339
|
-
|
|
1340
|
-
_automation_runtime = build_automation_runtime(
|
|
1341
|
-
store=WORKSPACE_OS,
|
|
1342
|
-
platform=PLATFORM,
|
|
1343
1352
|
data_dir=DATA_DIR,
|
|
1344
|
-
workspace_graph=_workspace_graph,
|
|
1345
1353
|
append_audit_event=append_audit_event,
|
|
1346
|
-
hooks=HOOKS_REGISTRY,
|
|
1347
1354
|
)
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1355
|
+
_llm_generate_sync = _platform_automation_runtime["_llm_generate_sync"]
|
|
1356
|
+
PLATFORM = _platform_automation_runtime["PLATFORM"]
|
|
1357
|
+
_automation_runtime = _platform_automation_runtime["_automation_runtime"]
|
|
1358
|
+
REVIEW_QUEUE = _platform_automation_runtime["REVIEW_QUEUE"]
|
|
1359
|
+
TRIGGER_SERVICE = _platform_automation_runtime["TRIGGER_SERVICE"]
|
|
1360
|
+
AGENT_RUNTIME = _platform_automation_runtime["AGENT_RUNTIME"]
|
|
1361
|
+
RUN_EXECUTOR = _platform_automation_runtime["RUN_EXECUTOR"]
|
|
1352
1362
|
bind_trigger_hook_runner(registry=HOOKS_REGISTRY, trigger_service=TRIGGER_SERVICE)
|
|
1353
1363
|
app.state.run_executor = RUN_EXECUTOR
|
|
1354
1364
|
app.state.run_reconciliation = RUN_EXECUTOR.reconcile_startup()
|
|
@@ -1451,21 +1461,13 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1451
1461
|
return None
|
|
1452
1462
|
return PLATFORM.allowed_scopes(user)
|
|
1453
1463
|
|
|
1454
|
-
|
|
1455
|
-
app,
|
|
1456
|
-
create_chat_router=create_chat_router,
|
|
1457
|
-
context=context,
|
|
1458
|
-
create_search_router=create_search_router,
|
|
1459
|
-
search_service=SEARCH_SERVICE,
|
|
1460
|
-
allowed_workspaces_for=_allowed_workspaces_for,
|
|
1461
|
-
require_user=require_user,
|
|
1462
|
-
embedding_info=_embedding_info,
|
|
1463
|
-
create_tools_router=create_tools_router,
|
|
1464
|
-
ingestion_pipeline=INGESTION_PIPELINE,
|
|
1464
|
+
tool_router_context = ToolRouterContext(
|
|
1465
1465
|
config=CONFIG,
|
|
1466
|
+
ingestion_pipeline=INGESTION_PIPELINE,
|
|
1466
1467
|
data_dir=DATA_DIR,
|
|
1467
1468
|
static_dir=STATIC_DIR,
|
|
1468
1469
|
model_router=router,
|
|
1470
|
+
require_user=require_user,
|
|
1469
1471
|
require_admin=require_admin,
|
|
1470
1472
|
get_current_user=get_current_user,
|
|
1471
1473
|
clear_history=clear_history,
|
|
@@ -1483,13 +1485,29 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1483
1485
|
install_mcp=install_mcp,
|
|
1484
1486
|
mcp_public_item=mcp_public_item,
|
|
1485
1487
|
hooks=HOOKS_REGISTRY,
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
+
)
|
|
1489
|
+
interaction_router_context = InteractionRouterContext(
|
|
1490
|
+
chat_context=context,
|
|
1491
|
+
search_service=SEARCH_SERVICE,
|
|
1492
|
+
allowed_workspaces_for=_allowed_workspaces_for,
|
|
1493
|
+
require_user=require_user,
|
|
1494
|
+
embedding_info=_embedding_info,
|
|
1495
|
+
tool_context=tool_router_context,
|
|
1496
|
+
hooks=HOOKS_REGISTRY,
|
|
1488
1497
|
agent_registry=AGENT_REGISTRY,
|
|
1489
|
-
create_memory_router=create_memory_router,
|
|
1490
1498
|
memory_service=MEMORY_SERVICE,
|
|
1491
1499
|
platform=PLATFORM,
|
|
1492
1500
|
)
|
|
1501
|
+
register_interaction_routers(
|
|
1502
|
+
app,
|
|
1503
|
+
interaction_context=interaction_router_context,
|
|
1504
|
+
create_chat_router=create_chat_router,
|
|
1505
|
+
create_search_router=create_search_router,
|
|
1506
|
+
create_tools_router=create_tools_router,
|
|
1507
|
+
create_hooks_router=create_hooks_router,
|
|
1508
|
+
create_agent_registry_router=create_agent_registry_router,
|
|
1509
|
+
create_memory_router=create_memory_router,
|
|
1510
|
+
)
|
|
1493
1511
|
|
|
1494
1512
|
from latticeai.api.review_queue import create_review_queue_router
|
|
1495
1513
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LatticeAI CLI package."""
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Command line entrypoint for Lattice AI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import socket
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from latticeai.cli.runtime import (
|
|
20
|
+
_apply_extra_path,
|
|
21
|
+
_has_module,
|
|
22
|
+
_load_env_file,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def _local_ips() -> list[str]:
|
|
26
|
+
ips: list[str] = []
|
|
27
|
+
try:
|
|
28
|
+
hostname = socket.gethostname()
|
|
29
|
+
for info in socket.getaddrinfo(hostname, None):
|
|
30
|
+
addr = info[4][0]
|
|
31
|
+
if ":" not in addr and not addr.startswith("127."):
|
|
32
|
+
if addr not in ips:
|
|
33
|
+
ips.append(addr)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
if not ips:
|
|
37
|
+
try:
|
|
38
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
39
|
+
s.connect(("8.8.8.8", 80))
|
|
40
|
+
ips.append(s.getsockname()[0])
|
|
41
|
+
s.close()
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
return ips
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print_banner(host: str, port: int, tunnel_url: str | None = None) -> None:
|
|
48
|
+
local_url = f"http://localhost:{port}"
|
|
49
|
+
print()
|
|
50
|
+
print("=" * 56)
|
|
51
|
+
print(" Lattice AI is running")
|
|
52
|
+
print(f" Local: {local_url}")
|
|
53
|
+
if host == "0.0.0.0":
|
|
54
|
+
for ip in _local_ips():
|
|
55
|
+
print(f" Network: http://{ip}:{port}")
|
|
56
|
+
print()
|
|
57
|
+
print(" Other devices on the same Wi-Fi can open the")
|
|
58
|
+
print(" Network URL above in their browser.")
|
|
59
|
+
print(" On iPad/Android: browser menu → 'Add to Home Screen'")
|
|
60
|
+
if tunnel_url:
|
|
61
|
+
print()
|
|
62
|
+
print(f" Tunnel: {tunnel_url}")
|
|
63
|
+
print(" Anyone on the internet can access via the Tunnel URL.")
|
|
64
|
+
print("=" * 56)
|
|
65
|
+
print()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def doctor() -> int:
|
|
69
|
+
checks = [
|
|
70
|
+
("Python 3.11+", sys.version_info >= (3, 11), sys.version.split()[0], True),
|
|
71
|
+
("FastAPI", _has_module("fastapi"), "required server dependency", True),
|
|
72
|
+
("Uvicorn", _has_module("uvicorn"), "required server dependency", True),
|
|
73
|
+
("OpenAI SDK", _has_module("openai"), "required for cloud providers", False),
|
|
74
|
+
("MLX", _has_module("mlx"), "required for Apple Silicon multimodal models", False),
|
|
75
|
+
("MLX-VLM", _has_module("mlx_vlm"), "required for Gemma-4/VLM models", False),
|
|
76
|
+
("Ollama binary", shutil.which("ollama") is not None, "optional local-server engine", False),
|
|
77
|
+
]
|
|
78
|
+
data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or Path.home() / ".ltcai")
|
|
79
|
+
static_dir = Path(os.getenv("LATTICEAI_STATIC_DIR") or Path(__file__).resolve().parent / "static")
|
|
80
|
+
checks.extend([
|
|
81
|
+
("Data dir", data_dir.exists() or data_dir.parent.exists(), str(data_dir), True),
|
|
82
|
+
("Static UI", static_dir.exists(), str(static_dir), True),
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
ok = True
|
|
86
|
+
for label, passed, detail, required in checks:
|
|
87
|
+
icon = "OK" if passed else ("MISS" if required else "OPTIONAL")
|
|
88
|
+
print(f"[{icon}] {label}: {detail}")
|
|
89
|
+
ok = ok and (passed or not required)
|
|
90
|
+
|
|
91
|
+
cloud_keys = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GROQ_API_KEY", "TOGETHER_API_KEY"]
|
|
92
|
+
configured = [key for key in cloud_keys if os.getenv(key)]
|
|
93
|
+
print(f"[INFO] Cloud keys configured: {', '.join(configured) if configured else 'none'}")
|
|
94
|
+
return 0 if ok else 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def _cloudflared_url() -> str:
|
|
100
|
+
system = sys.platform
|
|
101
|
+
machine = platform.machine().lower()
|
|
102
|
+
base = "https://github.com/cloudflare/cloudflared/releases/latest/download"
|
|
103
|
+
if system == "darwin":
|
|
104
|
+
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
105
|
+
return f"{base}/cloudflared-darwin-{arch}"
|
|
106
|
+
if system == "win32":
|
|
107
|
+
return f"{base}/cloudflared-windows-amd64.exe"
|
|
108
|
+
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
109
|
+
return f"{base}/cloudflared-linux-{arch}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cloudflared_bin() -> Path:
|
|
113
|
+
suffix = ".exe" if sys.platform == "win32" else ""
|
|
114
|
+
return Path.home() / ".latticeai" / "bin" / f"cloudflared{suffix}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ensure_cloudflared() -> str:
|
|
118
|
+
found = shutil.which("cloudflared")
|
|
119
|
+
if found:
|
|
120
|
+
return found
|
|
121
|
+
dest = _cloudflared_bin()
|
|
122
|
+
if dest.exists():
|
|
123
|
+
return str(dest)
|
|
124
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
url = _cloudflared_url()
|
|
126
|
+
print(" cloudflared not found — downloading from GitHub...")
|
|
127
|
+
try:
|
|
128
|
+
urllib.request.urlretrieve(url, dest)
|
|
129
|
+
if sys.platform != "win32":
|
|
130
|
+
dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
131
|
+
print(f" cloudflared installed at {dest}")
|
|
132
|
+
return str(dest)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print(f" cloudflared download failed: {e}")
|
|
135
|
+
print(" Install manually: https://developers.cloudflare.com/cloudflared/install")
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _send_telegram(token: str, chat_id: str, text: str) -> None:
|
|
140
|
+
try:
|
|
141
|
+
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
|
|
142
|
+
req = urllib.request.Request(
|
|
143
|
+
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
144
|
+
data=data,
|
|
145
|
+
)
|
|
146
|
+
urllib.request.urlopen(req, timeout=10)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _start_tunnel(port: int) -> str | None:
|
|
152
|
+
|
|
153
|
+
bin_path = _ensure_cloudflared()
|
|
154
|
+
if not bin_path:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
log_path = Path.home() / ".latticeai" / "tunnel.log"
|
|
158
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
proc = subprocess.Popen(
|
|
161
|
+
[bin_path, "tunnel", "--url", f"http://localhost:{port}"],
|
|
162
|
+
stdout=open(log_path, "w"),
|
|
163
|
+
stderr=subprocess.STDOUT,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Wait for the public URL (up to 30s)
|
|
167
|
+
pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
168
|
+
deadline = time.time() + 30
|
|
169
|
+
url: str | None = None
|
|
170
|
+
while time.time() < deadline:
|
|
171
|
+
time.sleep(0.5)
|
|
172
|
+
try:
|
|
173
|
+
text = log_path.read_text(errors="replace")
|
|
174
|
+
m = pattern.search(text)
|
|
175
|
+
if m:
|
|
176
|
+
url = m.group(0)
|
|
177
|
+
break
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if not url:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Telegram notification if configured
|
|
185
|
+
token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
186
|
+
chat_id = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
187
|
+
if token and chat_id:
|
|
188
|
+
msg = (
|
|
189
|
+
f"✅ Lattice AI 시작됨\n\n"
|
|
190
|
+
f"🌐 외부 URL: {url}\n"
|
|
191
|
+
f"🏠 로컬: http://localhost:{port}"
|
|
192
|
+
)
|
|
193
|
+
threading.Thread(target=_send_telegram, args=(token, chat_id, msg), daemon=True).start()
|
|
194
|
+
|
|
195
|
+
return url
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def main() -> None:
|
|
201
|
+
app_dir = Path(__file__).resolve().parent
|
|
202
|
+
_load_env_file(app_dir / ".env")
|
|
203
|
+
_apply_extra_path()
|
|
204
|
+
|
|
205
|
+
parser = argparse.ArgumentParser(prog="LTCAI", description="Run the Lattice AI local server.")
|
|
206
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
207
|
+
subparsers.add_parser("doctor", help="Check local runtime dependencies and configuration.")
|
|
208
|
+
parser.add_argument("--host", default=os.getenv("LATTICEAI_HOST") or "127.0.0.1")
|
|
209
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("LATTICEAI_PORT") or "4825"))
|
|
210
|
+
parser.add_argument("--reload", action="store_true", help="Enable uvicorn reload for local development.")
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--tunnel",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="Open a public Cloudflare tunnel so anyone can access this server from the internet.",
|
|
215
|
+
)
|
|
216
|
+
args = parser.parse_args()
|
|
217
|
+
|
|
218
|
+
if args.command == "doctor":
|
|
219
|
+
raise SystemExit(doctor())
|
|
220
|
+
|
|
221
|
+
os.chdir(app_dir)
|
|
222
|
+
|
|
223
|
+
if not args.tunnel and os.getenv("LATTICEAI_TUNNEL", "").lower() in (
|
|
224
|
+
"1",
|
|
225
|
+
"true",
|
|
226
|
+
"yes",
|
|
227
|
+
):
|
|
228
|
+
print(
|
|
229
|
+
" LATTICEAI_TUNNEL is ignored during default local startup; "
|
|
230
|
+
"restart with --tunnel to expose this server."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# --tunnel forces 0.0.0.0 so cloudflared can reach the server
|
|
234
|
+
if args.tunnel and args.host == "127.0.0.1":
|
|
235
|
+
args.host = "0.0.0.0"
|
|
236
|
+
os.environ.setdefault("LATTICEAI_HOST", "0.0.0.0")
|
|
237
|
+
os.environ.setdefault("LATTICEAI_CORS_ALLOW_NETWORK", "true")
|
|
238
|
+
os.environ.setdefault("LATTICEAI_REQUIRE_AUTH", "true")
|
|
239
|
+
|
|
240
|
+
# Keep the app config in sync with CLI flags. ``Config.from_env`` is the
|
|
241
|
+
# source of truth for /mode, /health.features, SSO defaults, and routers.
|
|
242
|
+
os.environ["LATTICEAI_HOST"] = str(args.host)
|
|
243
|
+
os.environ["LATTICEAI_PORT"] = str(args.port)
|
|
244
|
+
|
|
245
|
+
tunnel_url: str | None = None
|
|
246
|
+
if args.tunnel:
|
|
247
|
+
print()
|
|
248
|
+
print(" Starting Cloudflare tunnel...")
|
|
249
|
+
tunnel_url = _start_tunnel(args.port)
|
|
250
|
+
if not tunnel_url:
|
|
251
|
+
print(" ⚠️ Tunnel URL not obtained — server will start without tunnel.")
|
|
252
|
+
|
|
253
|
+
_print_banner(args.host, args.port, tunnel_url)
|
|
254
|
+
|
|
255
|
+
# Telegram startup notification (local start, tunnel handled separately inside _start_tunnel)
|
|
256
|
+
if not args.tunnel:
|
|
257
|
+
_tg_enabled = os.getenv("LATTICEAI_ENABLE_TELEGRAM", "").strip().lower() in ("1", "true", "yes", "on")
|
|
258
|
+
_tg_token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
259
|
+
_tg_chat = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
260
|
+
if _tg_enabled and _tg_token and _tg_chat:
|
|
261
|
+
_local_msg = (
|
|
262
|
+
f"✅ Lattice AI 시작됨\n\n"
|
|
263
|
+
f"🏠 로컬: http://localhost:{args.port}"
|
|
264
|
+
)
|
|
265
|
+
threading.Thread(
|
|
266
|
+
target=_send_telegram,
|
|
267
|
+
args=(_tg_token, _tg_chat, _local_msg),
|
|
268
|
+
daemon=True,
|
|
269
|
+
).start()
|
|
270
|
+
|
|
271
|
+
import uvicorn
|
|
272
|
+
|
|
273
|
+
uvicorn.run(
|
|
274
|
+
"server:app",
|
|
275
|
+
host=args.host,
|
|
276
|
+
port=args.port,
|
|
277
|
+
reload=args.reload,
|
|
278
|
+
log_level="info",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pure runtime helpers extracted from ltcai_cli entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_env_file(path: Path) -> None:
|
|
11
|
+
if not path.exists():
|
|
12
|
+
return
|
|
13
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
14
|
+
line = raw_line.strip()
|
|
15
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
16
|
+
continue
|
|
17
|
+
key, value = line.split("=", 1)
|
|
18
|
+
key = key.strip()
|
|
19
|
+
value = value.strip().strip('"').strip("'")
|
|
20
|
+
if key and key not in os.environ:
|
|
21
|
+
os.environ[key] = value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _apply_extra_path() -> None:
|
|
25
|
+
extra = os.getenv("LATTICEAI_EXTRA_PATH", "")
|
|
26
|
+
if not extra:
|
|
27
|
+
return
|
|
28
|
+
current = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
|
29
|
+
for item in reversed([p for p in extra.split(os.pathsep) if p]):
|
|
30
|
+
expanded = str(Path(item).expanduser())
|
|
31
|
+
if Path(expanded).exists() and expanded not in current:
|
|
32
|
+
current.insert(0, expanded)
|
|
33
|
+
os.environ["PATH"] = os.pathsep.join(current)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _has_module(name: str) -> bool:
|
|
37
|
+
return importlib.util.find_spec(name) is not None
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "6.
|
|
22
|
+
WORKSPACE_OS_VERSION = "6.2.0"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
File without changes
|