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.
Files changed (54) hide show
  1. package/README.md +33 -35
  2. package/docs/CHANGELOG.md +68 -0
  3. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +1 -1
  4. package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +1 -1
  5. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +1 -1
  6. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +1 -1
  7. package/frontend/src/App.tsx +3 -1281
  8. package/frontend/src/components/LanguageSwitcher.tsx +23 -0
  9. package/frontend/src/components/ProductFlow.tsx +32 -662
  10. package/frontend/src/components/onboarding/ProductFlowScreens.tsx +688 -0
  11. package/frontend/src/features/admin/AdminConsole.tsx +294 -0
  12. package/frontend/src/features/brain/BrainHome.tsx +999 -0
  13. package/frontend/src/features/brain/brainData.ts +98 -0
  14. package/frontend/src/features/brain/graphLayout.ts +26 -0
  15. package/frontend/src/features/brain/types.ts +44 -0
  16. package/frontend/src/features/review/ReviewCard.tsx +15 -10
  17. package/frontend/src/i18n.ts +208 -0
  18. package/frontend/src/styles.css +240 -0
  19. package/lattice_brain/__init__.py +1 -1
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +52 -33
  23. package/latticeai/api/tools.py +50 -23
  24. package/latticeai/app_factory.py +65 -47
  25. package/latticeai/cli/__init__.py +1 -0
  26. package/latticeai/cli/entrypoint.py +283 -0
  27. package/latticeai/cli/runtime.py +37 -0
  28. package/latticeai/core/marketplace.py +1 -1
  29. package/latticeai/core/workspace_os.py +1 -1
  30. package/latticeai/integrations/__init__.py +0 -0
  31. package/latticeai/integrations/telegram_bot.py +1009 -0
  32. package/latticeai/runtime/lifespan_runtime.py +1 -1
  33. package/latticeai/runtime/platform_runtime_wiring.py +87 -0
  34. package/latticeai/runtime/router_registration.py +49 -30
  35. package/latticeai/services/app_context.py +1 -0
  36. package/latticeai/services/p_reinforce.py +258 -0
  37. package/latticeai/services/router_context.py +52 -0
  38. package/latticeai/services/tool_dispatch.py +82 -25
  39. package/ltcai_cli.py +7 -305
  40. package/p_reinforce.py +4 -255
  41. package/package.json +2 -1
  42. package/scripts/release_smoke.py +133 -0
  43. package/scripts/wheel_smoke.py +4 -0
  44. package/src-tauri/Cargo.lock +1 -1
  45. package/src-tauri/Cargo.toml +1 -1
  46. package/src-tauri/tauri.conf.json +1 -1
  47. package/static/app/asset-manifest.json +5 -5
  48. package/static/app/assets/{index-xRn29gI8.css → index-B2-1Gm0q.css} +1 -1
  49. package/static/app/assets/index-D91Rz5--.js +16 -0
  50. package/static/app/assets/index-D91Rz5--.js.map +1 -0
  51. package/static/app/index.html +2 -2
  52. package/telegram_bot.py +9 -1008
  53. package/static/app/assets/index-D2zafMYb.js +0 -16
  54. 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
- global _load_users, _get_user_role
50
- _load_users = load_users
51
- _get_user_role = get_user_role
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 DEFAULT_TOOL_REGISTRY.policy_for(action_name, args)
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 DEFAULT_TOOL_REGISTRY.risk_level(action_name, args)
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 DEFAULT_TOOL_REGISTRY.permission(name, args or {})
127
+ return DEFAULT_TOOL_DISPATCH_SERVICE.permission(name, args or {})
64
128
 
65
129
 
66
130
  def list_tool_permissions() -> list:
67
- return DEFAULT_TOOL_REGISTRY.permissions()
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
- if tool_name not in ADMIN_ONLY_TOOLS:
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=agent_policy,
123
- risk_level=lambda policy: RISK_LEVEL_MAP.get(policy["risk"], "medium"),
124
- check_role=check_tool_role,
125
- tool_governance=TOOL_GOVERNANCE,
126
- file_create_actions=frozenset(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
- """Command line entrypoint for Lattice AI."""
1
+ """Compatibility shim for the historical root CLI module."""
2
2
 
3
- from __future__ import annotations
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