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
|
@@ -6,8 +6,9 @@ tool-response shaping are owned outside ``server_app``.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
from dataclasses import dataclass, field
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import Any, Callable, Dict, Optional
|
|
11
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
11
12
|
|
|
12
13
|
from fastapi import HTTPException
|
|
13
14
|
|
|
@@ -30,9 +31,6 @@ def _default_get_user_role(_email, _users=None) -> str:
|
|
|
30
31
|
return "user"
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
_load_users: Callable[[], Dict[str, Any]] = _default_load_users
|
|
34
|
-
_get_user_role: Callable[..., str] = _default_get_user_role
|
|
35
|
-
|
|
36
34
|
FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
|
|
37
35
|
TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
|
|
38
36
|
TOOL_GOVERNANCE_DEFAULT: ToolPolicy = DEFAULT_TOOL_REGISTRY.default_policy
|
|
@@ -41,41 +39,100 @@ LOCAL_WRITE_BLOCKED_PREFIXES = DEFAULT_TOOL_REGISTRY.local_write_blocked_prefixe
|
|
|
41
39
|
RISK_LEVEL_MAP = DEFAULT_TOOL_REGISTRY.risk_level_map
|
|
42
40
|
|
|
43
41
|
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolDispatchService:
|
|
44
|
+
"""Runtime-facing tool policy and authorization boundary.
|
|
45
|
+
|
|
46
|
+
``ToolRegistry`` owns the catalog and governance facts; this service owns
|
|
47
|
+
request/runtime authorization callbacks that must be configured during app
|
|
48
|
+
construction. Keeping those callbacks on an instance gives future runtime
|
|
49
|
+
assembly code an injectable seam while the module-level functions below
|
|
50
|
+
preserve the historical ``server_app`` surface.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
registry: Any = field(default_factory=lambda: DEFAULT_TOOL_REGISTRY)
|
|
54
|
+
load_users: Callable[[], Dict[str, Any]] = field(default=_default_load_users)
|
|
55
|
+
get_user_role: Callable[..., str] = field(default=_default_get_user_role)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def file_create_actions(self) -> frozenset[str]:
|
|
59
|
+
return self.registry.file_create_actions
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def tool_governance(self) -> Mapping[str, ToolPolicy]:
|
|
63
|
+
return self.registry.governance
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def risk_level_map(self) -> Mapping[str, str]:
|
|
67
|
+
return self.registry.risk_level_map
|
|
68
|
+
|
|
69
|
+
def configure(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
load_users: Callable[[], Dict[str, Any]],
|
|
73
|
+
get_user_role: Callable[..., str],
|
|
74
|
+
) -> None:
|
|
75
|
+
self.load_users = load_users
|
|
76
|
+
self.get_user_role = get_user_role
|
|
77
|
+
|
|
78
|
+
def policy_for(self, action_name: str, args: dict) -> ToolPolicy:
|
|
79
|
+
return self.registry.policy_for(action_name, args)
|
|
80
|
+
|
|
81
|
+
def risk_level(self, action_name: str, args: dict) -> str:
|
|
82
|
+
return self.registry.risk_level(action_name, args)
|
|
83
|
+
|
|
84
|
+
def risk_level_for_policy(self, policy: ToolPolicy) -> str:
|
|
85
|
+
return self.risk_level_map.get(policy["risk"], "medium")
|
|
86
|
+
|
|
87
|
+
def permission(self, name: str, args: Optional[dict] = None) -> ToolPermission:
|
|
88
|
+
return self.registry.permission(name, args or {})
|
|
89
|
+
|
|
90
|
+
def permissions(self) -> list[ToolPermission]:
|
|
91
|
+
return self.registry.permissions()
|
|
92
|
+
|
|
93
|
+
def check_role(self, tool_name: str, current_user: str) -> None:
|
|
94
|
+
if tool_name not in self.registry.admin_only_tools:
|
|
95
|
+
return
|
|
96
|
+
users = self.load_users()
|
|
97
|
+
if self.get_user_role(current_user, users) != "admin":
|
|
98
|
+
raise HTTPException(
|
|
99
|
+
status_code=403,
|
|
100
|
+
detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
DEFAULT_TOOL_DISPATCH_SERVICE = ToolDispatchService()
|
|
105
|
+
|
|
106
|
+
|
|
44
107
|
def configure_tool_dispatch(
|
|
45
108
|
*,
|
|
46
109
|
load_users: Callable[[], Dict[str, Any]],
|
|
47
110
|
get_user_role: Callable[..., str],
|
|
48
111
|
) -> None:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
112
|
+
DEFAULT_TOOL_DISPATCH_SERVICE.configure(
|
|
113
|
+
load_users=load_users,
|
|
114
|
+
get_user_role=get_user_role,
|
|
115
|
+
)
|
|
52
116
|
|
|
53
117
|
|
|
54
118
|
def agent_policy(action_name: str, args: dict) -> ToolPolicy:
|
|
55
|
-
return
|
|
119
|
+
return DEFAULT_TOOL_DISPATCH_SERVICE.policy_for(action_name, args)
|
|
56
120
|
|
|
57
121
|
|
|
58
122
|
def agent_risk(action_name: str, args: dict) -> str:
|
|
59
|
-
return
|
|
123
|
+
return DEFAULT_TOOL_DISPATCH_SERVICE.risk_level(action_name, args)
|
|
60
124
|
|
|
61
125
|
|
|
62
126
|
def get_tool_permission(name: str, args: Optional[dict] = None) -> ToolPermission:
|
|
63
|
-
return
|
|
127
|
+
return DEFAULT_TOOL_DISPATCH_SERVICE.permission(name, args or {})
|
|
64
128
|
|
|
65
129
|
|
|
66
130
|
def list_tool_permissions() -> list:
|
|
67
|
-
return
|
|
131
|
+
return DEFAULT_TOOL_DISPATCH_SERVICE.permissions()
|
|
68
132
|
|
|
69
133
|
|
|
70
134
|
def check_tool_role(tool_name: str, current_user: str) -> None:
|
|
71
|
-
|
|
72
|
-
return
|
|
73
|
-
users = _load_users()
|
|
74
|
-
if _get_user_role(current_user, users) != "admin":
|
|
75
|
-
raise HTTPException(
|
|
76
|
-
status_code=403,
|
|
77
|
-
detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
|
|
78
|
-
)
|
|
135
|
+
DEFAULT_TOOL_DISPATCH_SERVICE.check_role(tool_name, current_user)
|
|
79
136
|
|
|
80
137
|
|
|
81
138
|
def collect_created_files(transcript: list) -> list:
|
|
@@ -113,17 +170,18 @@ def build_agent_runtime(
|
|
|
113
170
|
audit: Callable[..., None],
|
|
114
171
|
hooks: Any = None,
|
|
115
172
|
brain_memory: Any = None,
|
|
173
|
+
dispatch_service: ToolDispatchService = DEFAULT_TOOL_DISPATCH_SERVICE,
|
|
116
174
|
) -> AgentRuntime:
|
|
117
175
|
ensure_agent_root()
|
|
118
176
|
deps = AgentDeps(
|
|
119
177
|
generate_as=model_router.generate_as,
|
|
120
178
|
generate=model_router.generate,
|
|
121
179
|
execute_tool=execute_tool,
|
|
122
|
-
policy_for=
|
|
123
|
-
risk_level=
|
|
124
|
-
check_role=
|
|
125
|
-
tool_governance=
|
|
126
|
-
file_create_actions=
|
|
180
|
+
policy_for=dispatch_service.policy_for,
|
|
181
|
+
risk_level=dispatch_service.risk_level_for_policy,
|
|
182
|
+
check_role=dispatch_service.check_role,
|
|
183
|
+
tool_governance=dispatch_service.tool_governance,
|
|
184
|
+
file_create_actions=dispatch_service.file_create_actions,
|
|
127
185
|
recent_chat_context=recent_chat_context,
|
|
128
186
|
clear_history=clear_history,
|
|
129
187
|
knowledge_save=knowledge_save,
|
|
@@ -144,4 +202,3 @@ def tool_response(fn, *args):
|
|
|
144
202
|
return {"status": "ok", "workspace": str(AGENT_ROOT), "result": fn(*args)}
|
|
145
203
|
except ToolError as exc:
|
|
146
204
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
147
|
-
|
package/ltcai_cli.py
CHANGED
|
@@ -1,309 +1,11 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Compatibility shim for the historical root CLI module."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
import importlib.util
|
|
7
|
-
import os
|
|
8
|
-
import platform
|
|
9
|
-
import re
|
|
10
|
-
import shutil
|
|
11
|
-
import socket
|
|
12
|
-
import stat
|
|
13
|
-
import subprocess
|
|
14
|
-
import sys
|
|
15
|
-
import threading
|
|
16
|
-
import time
|
|
17
|
-
import urllib.request
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _load_env_file(path: Path) -> None:
|
|
22
|
-
if not path.exists():
|
|
23
|
-
return
|
|
24
|
-
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
25
|
-
line = raw_line.strip()
|
|
26
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
27
|
-
continue
|
|
28
|
-
key, value = line.split("=", 1)
|
|
29
|
-
key = key.strip()
|
|
30
|
-
value = value.strip().strip('"').strip("'")
|
|
31
|
-
if key and key not in os.environ:
|
|
32
|
-
os.environ[key] = value
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _apply_extra_path() -> None:
|
|
36
|
-
extra = os.getenv("LATTICEAI_EXTRA_PATH", "")
|
|
37
|
-
if not extra:
|
|
38
|
-
return
|
|
39
|
-
current = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
|
40
|
-
for item in reversed([p for p in extra.split(os.pathsep) if p]):
|
|
41
|
-
expanded = str(Path(item).expanduser())
|
|
42
|
-
if Path(expanded).exists() and expanded not in current:
|
|
43
|
-
current.insert(0, expanded)
|
|
44
|
-
os.environ["PATH"] = os.pathsep.join(current)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _has_module(name: str) -> bool:
|
|
48
|
-
return importlib.util.find_spec(name) is not None
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _local_ips() -> list[str]:
|
|
52
|
-
ips: list[str] = []
|
|
53
|
-
try:
|
|
54
|
-
hostname = socket.gethostname()
|
|
55
|
-
for info in socket.getaddrinfo(hostname, None):
|
|
56
|
-
addr = info[4][0]
|
|
57
|
-
if ":" not in addr and not addr.startswith("127."):
|
|
58
|
-
if addr not in ips:
|
|
59
|
-
ips.append(addr)
|
|
60
|
-
except Exception:
|
|
61
|
-
pass
|
|
62
|
-
if not ips:
|
|
63
|
-
try:
|
|
64
|
-
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
65
|
-
s.connect(("8.8.8.8", 80))
|
|
66
|
-
ips.append(s.getsockname()[0])
|
|
67
|
-
s.close()
|
|
68
|
-
except Exception:
|
|
69
|
-
pass
|
|
70
|
-
return ips
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _print_banner(host: str, port: int, tunnel_url: str | None = None) -> None:
|
|
74
|
-
local_url = f"http://localhost:{port}"
|
|
75
|
-
print()
|
|
76
|
-
print("=" * 56)
|
|
77
|
-
print(" Lattice AI is running")
|
|
78
|
-
print(f" Local: {local_url}")
|
|
79
|
-
if host == "0.0.0.0":
|
|
80
|
-
for ip in _local_ips():
|
|
81
|
-
print(f" Network: http://{ip}:{port}")
|
|
82
|
-
print()
|
|
83
|
-
print(" Other devices on the same Wi-Fi can open the")
|
|
84
|
-
print(" Network URL above in their browser.")
|
|
85
|
-
print(" On iPad/Android: browser menu → 'Add to Home Screen'")
|
|
86
|
-
if tunnel_url:
|
|
87
|
-
print()
|
|
88
|
-
print(f" Tunnel: {tunnel_url}")
|
|
89
|
-
print(" Anyone on the internet can access via the Tunnel URL.")
|
|
90
|
-
print("=" * 56)
|
|
91
|
-
print()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def doctor() -> int:
|
|
95
|
-
checks = [
|
|
96
|
-
("Python 3.11+", sys.version_info >= (3, 11), sys.version.split()[0], True),
|
|
97
|
-
("FastAPI", _has_module("fastapi"), "required server dependency", True),
|
|
98
|
-
("Uvicorn", _has_module("uvicorn"), "required server dependency", True),
|
|
99
|
-
("OpenAI SDK", _has_module("openai"), "required for cloud providers", False),
|
|
100
|
-
("MLX", _has_module("mlx"), "required for Apple Silicon multimodal models", False),
|
|
101
|
-
("MLX-VLM", _has_module("mlx_vlm"), "required for Gemma-4/VLM models", False),
|
|
102
|
-
("Ollama binary", shutil.which("ollama") is not None, "optional local-server engine", False),
|
|
103
|
-
]
|
|
104
|
-
data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or Path.home() / ".ltcai")
|
|
105
|
-
static_dir = Path(os.getenv("LATTICEAI_STATIC_DIR") or Path(__file__).resolve().parent / "static")
|
|
106
|
-
checks.extend([
|
|
107
|
-
("Data dir", data_dir.exists() or data_dir.parent.exists(), str(data_dir), True),
|
|
108
|
-
("Static UI", static_dir.exists(), str(static_dir), True),
|
|
109
|
-
])
|
|
110
|
-
|
|
111
|
-
ok = True
|
|
112
|
-
for label, passed, detail, required in checks:
|
|
113
|
-
icon = "OK" if passed else ("MISS" if required else "OPTIONAL")
|
|
114
|
-
print(f"[{icon}] {label}: {detail}")
|
|
115
|
-
ok = ok and (passed or not required)
|
|
116
|
-
|
|
117
|
-
cloud_keys = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GROQ_API_KEY", "TOGETHER_API_KEY"]
|
|
118
|
-
configured = [key for key in cloud_keys if os.getenv(key)]
|
|
119
|
-
print(f"[INFO] Cloud keys configured: {', '.join(configured) if configured else 'none'}")
|
|
120
|
-
return 0 if ok else 1
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
# ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
def _cloudflared_url() -> str:
|
|
126
|
-
system = sys.platform
|
|
127
|
-
machine = platform.machine().lower()
|
|
128
|
-
base = "https://github.com/cloudflare/cloudflared/releases/latest/download"
|
|
129
|
-
if system == "darwin":
|
|
130
|
-
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
131
|
-
return f"{base}/cloudflared-darwin-{arch}"
|
|
132
|
-
if system == "win32":
|
|
133
|
-
return f"{base}/cloudflared-windows-amd64.exe"
|
|
134
|
-
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
135
|
-
return f"{base}/cloudflared-linux-{arch}"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _cloudflared_bin() -> Path:
|
|
139
|
-
suffix = ".exe" if sys.platform == "win32" else ""
|
|
140
|
-
return Path.home() / ".latticeai" / "bin" / f"cloudflared{suffix}"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _ensure_cloudflared() -> str:
|
|
144
|
-
found = shutil.which("cloudflared")
|
|
145
|
-
if found:
|
|
146
|
-
return found
|
|
147
|
-
dest = _cloudflared_bin()
|
|
148
|
-
if dest.exists():
|
|
149
|
-
return str(dest)
|
|
150
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
-
url = _cloudflared_url()
|
|
152
|
-
print(" cloudflared not found — downloading from GitHub...")
|
|
153
|
-
try:
|
|
154
|
-
urllib.request.urlretrieve(url, dest)
|
|
155
|
-
if sys.platform != "win32":
|
|
156
|
-
dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
157
|
-
print(f" cloudflared installed at {dest}")
|
|
158
|
-
return str(dest)
|
|
159
|
-
except Exception as e:
|
|
160
|
-
print(f" cloudflared download failed: {e}")
|
|
161
|
-
print(" Install manually: https://developers.cloudflare.com/cloudflared/install")
|
|
162
|
-
return ""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def _send_telegram(token: str, chat_id: str, text: str) -> None:
|
|
166
|
-
try:
|
|
167
|
-
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
|
|
168
|
-
req = urllib.request.Request(
|
|
169
|
-
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
170
|
-
data=data,
|
|
171
|
-
)
|
|
172
|
-
urllib.request.urlopen(req, timeout=10)
|
|
173
|
-
except Exception:
|
|
174
|
-
pass
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _start_tunnel(port: int) -> str | None:
|
|
178
|
-
|
|
179
|
-
bin_path = _ensure_cloudflared()
|
|
180
|
-
if not bin_path:
|
|
181
|
-
return None
|
|
182
|
-
|
|
183
|
-
log_path = Path.home() / ".latticeai" / "tunnel.log"
|
|
184
|
-
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
-
|
|
186
|
-
proc = subprocess.Popen(
|
|
187
|
-
[bin_path, "tunnel", "--url", f"http://localhost:{port}"],
|
|
188
|
-
stdout=open(log_path, "w"),
|
|
189
|
-
stderr=subprocess.STDOUT,
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
# Wait for the public URL (up to 30s)
|
|
193
|
-
pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
194
|
-
deadline = time.time() + 30
|
|
195
|
-
url: str | None = None
|
|
196
|
-
while time.time() < deadline:
|
|
197
|
-
time.sleep(0.5)
|
|
198
|
-
try:
|
|
199
|
-
text = log_path.read_text(errors="replace")
|
|
200
|
-
m = pattern.search(text)
|
|
201
|
-
if m:
|
|
202
|
-
url = m.group(0)
|
|
203
|
-
break
|
|
204
|
-
except Exception:
|
|
205
|
-
pass
|
|
206
|
-
|
|
207
|
-
if not url:
|
|
208
|
-
return None
|
|
209
|
-
|
|
210
|
-
# Telegram notification if configured
|
|
211
|
-
token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
212
|
-
chat_id = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
213
|
-
if token and chat_id:
|
|
214
|
-
msg = (
|
|
215
|
-
f"✅ Lattice AI 시작됨\n\n"
|
|
216
|
-
f"🌐 외부 URL: {url}\n"
|
|
217
|
-
f"🏠 로컬: http://localhost:{port}"
|
|
218
|
-
)
|
|
219
|
-
threading.Thread(target=_send_telegram, args=(token, chat_id, msg), daemon=True).start()
|
|
220
|
-
|
|
221
|
-
return url
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
-
|
|
226
|
-
def main() -> None:
|
|
227
|
-
app_dir = Path(__file__).resolve().parent
|
|
228
|
-
_load_env_file(app_dir / ".env")
|
|
229
|
-
_apply_extra_path()
|
|
230
|
-
|
|
231
|
-
parser = argparse.ArgumentParser(prog="LTCAI", description="Run the Lattice AI local server.")
|
|
232
|
-
subparsers = parser.add_subparsers(dest="command")
|
|
233
|
-
subparsers.add_parser("doctor", help="Check local runtime dependencies and configuration.")
|
|
234
|
-
parser.add_argument("--host", default=os.getenv("LATTICEAI_HOST") or "127.0.0.1")
|
|
235
|
-
parser.add_argument("--port", type=int, default=int(os.getenv("LATTICEAI_PORT") or "4825"))
|
|
236
|
-
parser.add_argument("--reload", action="store_true", help="Enable uvicorn reload for local development.")
|
|
237
|
-
parser.add_argument(
|
|
238
|
-
"--tunnel",
|
|
239
|
-
action="store_true",
|
|
240
|
-
help="Open a public Cloudflare tunnel so anyone can access this server from the internet.",
|
|
241
|
-
)
|
|
242
|
-
args = parser.parse_args()
|
|
243
|
-
|
|
244
|
-
if args.command == "doctor":
|
|
245
|
-
raise SystemExit(doctor())
|
|
246
|
-
|
|
247
|
-
os.chdir(app_dir)
|
|
248
|
-
|
|
249
|
-
if not args.tunnel and os.getenv("LATTICEAI_TUNNEL", "").lower() in (
|
|
250
|
-
"1",
|
|
251
|
-
"true",
|
|
252
|
-
"yes",
|
|
253
|
-
):
|
|
254
|
-
print(
|
|
255
|
-
" LATTICEAI_TUNNEL is ignored during default local startup; "
|
|
256
|
-
"restart with --tunnel to expose this server."
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
# --tunnel forces 0.0.0.0 so cloudflared can reach the server
|
|
260
|
-
if args.tunnel and args.host == "127.0.0.1":
|
|
261
|
-
args.host = "0.0.0.0"
|
|
262
|
-
os.environ.setdefault("LATTICEAI_HOST", "0.0.0.0")
|
|
263
|
-
os.environ.setdefault("LATTICEAI_CORS_ALLOW_NETWORK", "true")
|
|
264
|
-
os.environ.setdefault("LATTICEAI_REQUIRE_AUTH", "true")
|
|
265
|
-
|
|
266
|
-
# Keep the app config in sync with CLI flags. ``Config.from_env`` is the
|
|
267
|
-
# source of truth for /mode, /health.features, SSO defaults, and routers.
|
|
268
|
-
os.environ["LATTICEAI_HOST"] = str(args.host)
|
|
269
|
-
os.environ["LATTICEAI_PORT"] = str(args.port)
|
|
270
|
-
|
|
271
|
-
tunnel_url: str | None = None
|
|
272
|
-
if args.tunnel:
|
|
273
|
-
print()
|
|
274
|
-
print(" Starting Cloudflare tunnel...")
|
|
275
|
-
tunnel_url = _start_tunnel(args.port)
|
|
276
|
-
if not tunnel_url:
|
|
277
|
-
print(" ⚠️ Tunnel URL not obtained — server will start without tunnel.")
|
|
278
|
-
|
|
279
|
-
_print_banner(args.host, args.port, tunnel_url)
|
|
280
|
-
|
|
281
|
-
# Telegram startup notification (local start, tunnel handled separately inside _start_tunnel)
|
|
282
|
-
if not args.tunnel:
|
|
283
|
-
_tg_enabled = os.getenv("LATTICEAI_ENABLE_TELEGRAM", "").strip().lower() in ("1", "true", "yes", "on")
|
|
284
|
-
_tg_token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
285
|
-
_tg_chat = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
286
|
-
if _tg_enabled and _tg_token and _tg_chat:
|
|
287
|
-
_local_msg = (
|
|
288
|
-
f"✅ Lattice AI 시작됨\n\n"
|
|
289
|
-
f"🏠 로컬: http://localhost:{args.port}"
|
|
290
|
-
)
|
|
291
|
-
threading.Thread(
|
|
292
|
-
target=_send_telegram,
|
|
293
|
-
args=(_tg_token, _tg_chat, _local_msg),
|
|
294
|
-
daemon=True,
|
|
295
|
-
).start()
|
|
296
|
-
|
|
297
|
-
import uvicorn
|
|
298
|
-
|
|
299
|
-
uvicorn.run(
|
|
300
|
-
"server:app",
|
|
301
|
-
host=args.host,
|
|
302
|
-
port=args.port,
|
|
303
|
-
reload=args.reload,
|
|
304
|
-
log_level="info",
|
|
305
|
-
)
|
|
3
|
+
from latticeai.cli import entrypoint as _impl
|
|
306
4
|
|
|
307
5
|
|
|
308
6
|
if __name__ == "__main__":
|
|
309
|
-
main()
|
|
7
|
+
_impl.main()
|
|
8
|
+
else:
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.modules[__name__] = _impl
|