ltcai 0.1.11 → 0.1.16
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 +49 -8
- package/docs/CHANGELOG.md +80 -0
- package/knowledge_graph.py +123 -15
- package/llm_router.py +100 -28
- package/ltcai_cli.py +137 -4
- package/package.json +14 -2
- package/server.py +759 -68
- package/static/account.html +53 -51
- package/static/admin.html +50 -46
- package/static/chat.html +124 -96
- package/static/graph.html +1231 -337
- package/static/manifest.json +2 -2
package/ltcai_cli.py
CHANGED
|
@@ -5,9 +5,16 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import importlib.util
|
|
7
7
|
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
8
10
|
import shutil
|
|
9
11
|
import socket
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
10
14
|
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
11
18
|
from pathlib import Path
|
|
12
19
|
|
|
13
20
|
|
|
@@ -16,7 +23,6 @@ def _has_module(name: str) -> bool:
|
|
|
16
23
|
|
|
17
24
|
|
|
18
25
|
def _local_ips() -> list[str]:
|
|
19
|
-
"""Return all non-loopback IPv4 addresses for this machine."""
|
|
20
26
|
ips: list[str] = []
|
|
21
27
|
try:
|
|
22
28
|
hostname = socket.gethostname()
|
|
@@ -38,7 +44,7 @@ def _local_ips() -> list[str]:
|
|
|
38
44
|
return ips
|
|
39
45
|
|
|
40
46
|
|
|
41
|
-
def _print_banner(host: str, port: int) -> None:
|
|
47
|
+
def _print_banner(host: str, port: int, tunnel_url: str | None = None) -> None:
|
|
42
48
|
local_url = f"http://localhost:{port}"
|
|
43
49
|
print()
|
|
44
50
|
print("=" * 56)
|
|
@@ -51,6 +57,10 @@ def _print_banner(host: str, port: int) -> None:
|
|
|
51
57
|
print(" Other devices on the same Wi-Fi can open the")
|
|
52
58
|
print(" Network URL above in their browser.")
|
|
53
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.")
|
|
54
64
|
print("=" * 56)
|
|
55
65
|
print()
|
|
56
66
|
|
|
@@ -85,14 +95,122 @@ def doctor() -> int:
|
|
|
85
95
|
return 0 if ok else 1
|
|
86
96
|
|
|
87
97
|
|
|
98
|
+
# ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def _cloudflared_url() -> str:
|
|
101
|
+
system = sys.platform
|
|
102
|
+
machine = platform.machine().lower()
|
|
103
|
+
base = "https://github.com/cloudflare/cloudflared/releases/latest/download"
|
|
104
|
+
if system == "darwin":
|
|
105
|
+
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
106
|
+
return f"{base}/cloudflared-darwin-{arch}"
|
|
107
|
+
if system == "win32":
|
|
108
|
+
return f"{base}/cloudflared-windows-amd64.exe"
|
|
109
|
+
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
110
|
+
return f"{base}/cloudflared-linux-{arch}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cloudflared_bin() -> Path:
|
|
114
|
+
suffix = ".exe" if sys.platform == "win32" else ""
|
|
115
|
+
return Path.home() / ".latticeai" / "bin" / f"cloudflared{suffix}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ensure_cloudflared() -> str:
|
|
119
|
+
found = shutil.which("cloudflared")
|
|
120
|
+
if found:
|
|
121
|
+
return found
|
|
122
|
+
dest = _cloudflared_bin()
|
|
123
|
+
if dest.exists():
|
|
124
|
+
return str(dest)
|
|
125
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
url = _cloudflared_url()
|
|
127
|
+
print(f" cloudflared not found — downloading from GitHub...")
|
|
128
|
+
try:
|
|
129
|
+
urllib.request.urlretrieve(url, dest)
|
|
130
|
+
if sys.platform != "win32":
|
|
131
|
+
dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
132
|
+
print(f" cloudflared installed at {dest}")
|
|
133
|
+
return str(dest)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f" cloudflared download failed: {e}")
|
|
136
|
+
print(" Install manually: https://developers.cloudflare.com/cloudflared/install")
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _send_telegram(token: str, chat_id: str, text: str) -> None:
|
|
141
|
+
try:
|
|
142
|
+
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
|
|
143
|
+
req = urllib.request.Request(
|
|
144
|
+
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
145
|
+
data=data,
|
|
146
|
+
)
|
|
147
|
+
urllib.request.urlopen(req, timeout=10)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _start_tunnel(port: int) -> str | None:
|
|
153
|
+
import urllib.parse
|
|
154
|
+
|
|
155
|
+
bin_path = _ensure_cloudflared()
|
|
156
|
+
if not bin_path:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
log_path = Path.home() / ".latticeai" / "tunnel.log"
|
|
160
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
|
|
162
|
+
proc = subprocess.Popen(
|
|
163
|
+
[bin_path, "tunnel", "--url", f"http://localhost:{port}"],
|
|
164
|
+
stdout=open(log_path, "w"),
|
|
165
|
+
stderr=subprocess.STDOUT,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Wait for the public URL (up to 30s)
|
|
169
|
+
pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
170
|
+
deadline = time.time() + 30
|
|
171
|
+
url: str | None = None
|
|
172
|
+
while time.time() < deadline:
|
|
173
|
+
time.sleep(0.5)
|
|
174
|
+
try:
|
|
175
|
+
text = log_path.read_text(errors="replace")
|
|
176
|
+
m = pattern.search(text)
|
|
177
|
+
if m:
|
|
178
|
+
url = m.group(0)
|
|
179
|
+
break
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
if not url:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Telegram notification if configured
|
|
187
|
+
token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
188
|
+
chat_id = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
189
|
+
if token and chat_id:
|
|
190
|
+
msg = (
|
|
191
|
+
f"✅ Lattice AI 시작됨\n\n"
|
|
192
|
+
f"🌐 외부 URL: {url}\n"
|
|
193
|
+
f"🏠 로컬: http://localhost:{port}"
|
|
194
|
+
)
|
|
195
|
+
threading.Thread(target=_send_telegram, args=(token, chat_id, msg), daemon=True).start()
|
|
196
|
+
|
|
197
|
+
return url
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
88
202
|
def main() -> None:
|
|
89
203
|
parser = argparse.ArgumentParser(prog="LTCAI", description="Run the Lattice AI local server.")
|
|
90
204
|
subparsers = parser.add_subparsers(dest="command")
|
|
91
205
|
subparsers.add_parser("doctor", help="Check local runtime dependencies and configuration.")
|
|
92
|
-
# Default to 127.0.0.1; set LATTICEAI_HOST=0.0.0.0 to expose to the local network
|
|
93
206
|
parser.add_argument("--host", default=os.getenv("LATTICEAI_HOST") or "127.0.0.1")
|
|
94
207
|
parser.add_argument("--port", type=int, default=int(os.getenv("LATTICEAI_PORT") or "4825"))
|
|
95
208
|
parser.add_argument("--reload", action="store_true", help="Enable uvicorn reload for local development.")
|
|
209
|
+
parser.add_argument(
|
|
210
|
+
"--tunnel",
|
|
211
|
+
action="store_true",
|
|
212
|
+
help="Open a public Cloudflare tunnel so anyone can access this server from the internet.",
|
|
213
|
+
)
|
|
96
214
|
args = parser.parse_args()
|
|
97
215
|
|
|
98
216
|
if args.command == "doctor":
|
|
@@ -101,7 +219,22 @@ def main() -> None:
|
|
|
101
219
|
app_dir = Path(__file__).resolve().parent
|
|
102
220
|
os.chdir(app_dir)
|
|
103
221
|
|
|
104
|
-
|
|
222
|
+
# --tunnel forces 0.0.0.0 so cloudflared can reach the server
|
|
223
|
+
if args.tunnel and args.host == "127.0.0.1":
|
|
224
|
+
args.host = "0.0.0.0"
|
|
225
|
+
os.environ.setdefault("LATTICEAI_HOST", "0.0.0.0")
|
|
226
|
+
os.environ.setdefault("LATTICEAI_CORS_ALLOW_NETWORK", "true")
|
|
227
|
+
os.environ.setdefault("LATTICEAI_REQUIRE_AUTH", "true")
|
|
228
|
+
|
|
229
|
+
tunnel_url: str | None = None
|
|
230
|
+
if args.tunnel:
|
|
231
|
+
print()
|
|
232
|
+
print(" Starting Cloudflare tunnel...")
|
|
233
|
+
tunnel_url = _start_tunnel(args.port)
|
|
234
|
+
if not tunnel_url:
|
|
235
|
+
print(" ⚠️ Tunnel URL not obtained — server will start without tunnel.")
|
|
236
|
+
|
|
237
|
+
_print_banner(args.host, args.port, tunnel_url)
|
|
105
238
|
|
|
106
239
|
import uvicorn
|
|
107
240
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Lattice AI local MLX/cloud LLM workspace server",
|
|
5
|
+
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/TaeSooPark-PTS/LatticeAI.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/TaeSooPark-PTS/LatticeAI/issues"
|
|
12
|
+
},
|
|
5
13
|
"bin": {
|
|
6
14
|
"ltcai": "bin/ltcai.js",
|
|
7
15
|
"LTCAI": "bin/ltcai.js"
|
|
@@ -19,10 +27,14 @@
|
|
|
19
27
|
},
|
|
20
28
|
"keywords": [
|
|
21
29
|
"ltcai",
|
|
30
|
+
"ai-assistant",
|
|
22
31
|
"llm",
|
|
23
32
|
"mlx",
|
|
24
33
|
"local-ai",
|
|
25
|
-
"agent"
|
|
34
|
+
"agent",
|
|
35
|
+
"mcp",
|
|
36
|
+
"rag",
|
|
37
|
+
"vscode"
|
|
26
38
|
],
|
|
27
39
|
"license": "MIT",
|
|
28
40
|
"private": false,
|