ltcai 6.1.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 (44) hide show
  1. package/README.md +33 -38
  2. package/docs/CHANGELOG.md +33 -1
  3. package/frontend/src/App.tsx +3 -1286
  4. package/frontend/src/components/LanguageSwitcher.tsx +23 -0
  5. package/frontend/src/components/ProductFlow.tsx +32 -669
  6. package/frontend/src/components/onboarding/ProductFlowScreens.tsx +688 -0
  7. package/frontend/src/features/admin/AdminConsole.tsx +294 -0
  8. package/frontend/src/features/brain/BrainHome.tsx +999 -0
  9. package/frontend/src/features/brain/brainData.ts +98 -0
  10. package/frontend/src/features/brain/graphLayout.ts +26 -0
  11. package/frontend/src/features/brain/types.ts +44 -0
  12. package/frontend/src/i18n.ts +198 -0
  13. package/frontend/src/styles.css +220 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/runtime/multi_agent.py +1 -1
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/tools.py +50 -23
  18. package/latticeai/app_factory.py +36 -45
  19. package/latticeai/cli/entrypoint.py +283 -0
  20. package/latticeai/core/marketplace.py +1 -1
  21. package/latticeai/core/workspace_os.py +1 -1
  22. package/latticeai/integrations/__init__.py +0 -0
  23. package/latticeai/integrations/telegram_bot.py +1009 -0
  24. package/latticeai/runtime/lifespan_runtime.py +1 -1
  25. package/latticeai/runtime/platform_runtime_wiring.py +87 -0
  26. package/latticeai/runtime/router_registration.py +49 -30
  27. package/latticeai/services/p_reinforce.py +258 -0
  28. package/latticeai/services/router_context.py +52 -0
  29. package/ltcai_cli.py +7 -279
  30. package/p_reinforce.py +4 -255
  31. package/package.json +2 -1
  32. package/scripts/release_smoke.py +133 -0
  33. package/scripts/wheel_smoke.py +4 -0
  34. package/src-tauri/Cargo.lock +1 -1
  35. package/src-tauri/Cargo.toml +1 -1
  36. package/src-tauri/tauri.conf.json +1 -1
  37. package/static/app/asset-manifest.json +5 -5
  38. package/static/app/assets/{index-B744yblP.css → index-B2-1Gm0q.css} +1 -1
  39. package/static/app/assets/index-D91Rz5--.js +16 -0
  40. package/static/app/assets/index-D91Rz5--.js.map +1 -0
  41. package/static/app/index.html +2 -2
  42. package/telegram_bot.py +9 -1002
  43. package/static/app/assets/index-DYaUKNfl.js +0 -16
  44. package/static/app/assets/index-DYaUKNfl.js.map +0 -1
package/telegram_bot.py CHANGED
@@ -1,1009 +1,16 @@
1
- import asyncio
2
- import httpx
3
- import logging
4
- import base64
5
- import os
6
- import socket
7
- import tempfile
8
- import time
9
- import zipfile
10
- import json
11
- from pathlib import Path
1
+ """Compatibility shim for the historical root Telegram bot module."""
12
2
 
13
- from latticeai.core.logging_safety import install_sensitive_log_filter, safe_log_text
14
- from latticeai.cli.runtime import _load_env_file
3
+ from latticeai.integrations import telegram_bot as _impl
15
4
 
16
- install_sensitive_log_filter()
17
-
18
- def load_env_file(path=".env"):
19
- # Single source of truth: shared with ltcai_cli via latticeai.cli.runtime.
20
- _load_env_file(Path(path))
21
-
22
- load_env_file()
23
-
24
- def env_value(primary: str, default: str = "") -> str:
25
- return os.getenv(primary) or default
26
-
27
- TOKEN = env_value("LATTICEAI_TELEGRAM_BOT_TOKEN")
28
- API_URL = f"https://api.telegram.org/bot{TOKEN}"
29
- BASE_URL = "http://127.0.0.1:4825"
30
- CHAT_URL = f"{BASE_URL}/chat"
31
- AGENT_URL = f"{BASE_URL}/agent"
32
- MCP_TOOLS_URL = f"{BASE_URL}/mcp/tools"
33
- HISTORY_URL = f"{BASE_URL}/history"
34
- STATUS_URL = f"{BASE_URL}/status"
35
- MODELS_URL = f"{BASE_URL}/models"
36
- GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
37
- UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
38
-
39
- AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
40
- AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
41
-
42
- # Pending plan approvals: context_id → (chat_id, executing_model, reviewing_model)
43
- _bot_pending_plans: dict[str, dict] = {}
44
- MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
45
- SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
46
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
47
- PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
48
- DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
49
- DATA_DIR.mkdir(parents=True, exist_ok=True)
50
- CHAT_IDS_FILE = Path(env_value("LATTICEAI_TELEGRAM_CHATS_FILE", str(DATA_DIR / "telegram_chats.json")))
51
-
52
- logging.basicConfig(level=logging.INFO)
53
- logger = logging.getLogger(__name__)
54
-
55
- # ── Server session auth ───────────────────────────────────────────────────────
56
-
57
- def _get_server_session() -> str:
58
- """Read the most recent valid admin session from sessions.json (web login)."""
59
- sessions_file = DATA_DIR / "sessions.json"
60
- users_file = DATA_DIR / "users.json"
61
- try:
62
- if not sessions_file.exists():
63
- return ""
64
- sessions = json.loads(sessions_file.read_text())
65
- admin_emails: set[str] = set()
66
- if users_file.exists():
67
- users = json.loads(users_file.read_text())
68
- admin_emails = {e for e, u in users.items() if u.get("role") == "admin" and not u.get("disabled")}
69
- now = time.time()
70
- # Pick the newest non-expired admin session
71
- best_token, best_ts = "", 0.0
72
- for token, entry in sessions.items():
73
- email, created_at = entry[0], float(entry[1])
74
- if admin_emails and email not in admin_emails:
75
- continue
76
- if now - created_at > 7 * 86400:
77
- continue
78
- if created_at > best_ts:
79
- best_token, best_ts = token, created_at
80
- return best_token
81
- except Exception:
82
- return ""
83
-
84
- def _server_client(**kwargs) -> httpx.AsyncClient:
85
- """httpx client pre-loaded with the web session cookie."""
86
- token = _get_server_session()
87
- cookies = {"session_token": token} if token else {}
88
- return httpx.AsyncClient(cookies=cookies, **kwargs)
89
-
90
- # ── Chat ID registry ─────────────────────────────────────────────────────────
91
-
92
- def load_chat_ids():
93
- try:
94
- if CHAT_IDS_FILE.exists():
95
- data = json.loads(CHAT_IDS_FILE.read_text(encoding="utf-8"))
96
- return {int(cid) for cid in data.get("chat_ids", [])}
97
- except Exception as e:
98
- logger.error("텔레그램 채팅 목록 로드 실패: %s", safe_log_text(e))
99
- return set()
100
-
101
- def save_chat_ids(chat_ids):
102
- try:
103
- CHAT_IDS_FILE.write_text(
104
- json.dumps({"chat_ids": sorted(chat_ids)}, ensure_ascii=False, indent=2),
105
- encoding="utf-8",
106
- )
107
- except Exception as e:
108
- logger.error("텔레그램 채팅 목록 저장 실패: %s", safe_log_text(e))
109
-
110
- def register_chat_id(chat_id):
111
- chat_ids = load_chat_ids()
112
- if chat_id not in chat_ids:
113
- chat_ids.add(chat_id)
114
- save_chat_ids(chat_ids)
115
- logger.info("텔레그램 웹 미러링 대상 등록: %s", chat_id)
116
-
117
- # ── Telegram API helpers ──────────────────────────────────────────────────────
118
-
119
- async def send_message(client, chat_id, text, reply_markup=None):
120
- url = f"{API_URL}/sendMessage"
121
- try:
122
- chunks = [text[i:i+3900] for i in range(0, len(text), 3900)] or [""]
123
- for i, chunk in enumerate(chunks):
124
- payload = {"chat_id": chat_id, "text": chunk}
125
- if reply_markup and i == len(chunks) - 1:
126
- payload["reply_markup"] = reply_markup
127
- await client.post(url, json=payload)
128
- except Exception as e:
129
- logger.error("메시지 전송 실패: %s", safe_log_text(e))
130
-
131
- async def send_photo(client, chat_id, file_path: Path, caption: str = ""):
132
- url = f"{API_URL}/sendPhoto"
133
- try:
134
- with open(file_path, "rb") as f:
135
- res = await client.post(url, data={"chat_id": str(chat_id), "caption": caption[:1024]},
136
- files={"photo": (file_path.name, f)}, timeout=60.0)
137
- if res.status_code != 200:
138
- await send_message(client, chat_id, f"사진 전송 실패 ({res.status_code})")
139
- except Exception as e:
140
- logger.error("사진 전송 실패: %s", safe_log_text(e))
141
- await send_message(client, chat_id, f"사진 전송 오류: {safe_log_text(e)}")
142
-
143
- async def send_document(client, chat_id, file_path, caption=None, filename=None):
144
- url = f"{API_URL}/sendDocument"
145
- try:
146
- with open(file_path, "rb") as f:
147
- res = await client.post(
148
- url,
149
- data={"chat_id": str(chat_id), **({"caption": caption[:1024]} if caption else {})},
150
- files={"document": (filename or Path(file_path).name, f)},
151
- timeout=300.0,
152
- )
153
- if res.status_code != 200:
154
- logger.error("파일 전송 실패 (%s): %s", res.status_code, safe_log_text(res.text))
155
- except Exception as e:
156
- logger.error("파일 전송 실패: %s", safe_log_text(e))
157
-
158
- async def send_chat_action(client, chat_id, action="typing"):
159
- try:
160
- await client.post(f"{API_URL}/sendChatAction", json={"chat_id": chat_id, "action": action})
161
- except Exception:
162
- pass
163
-
164
- async def answer_callback(client, callback_query_id, text=""):
165
- try:
166
- await client.post(f"{API_URL}/answerCallbackQuery",
167
- json={"callback_query_id": callback_query_id, "text": text})
168
- except Exception:
169
- pass
170
-
171
- async def edit_message(client, chat_id, message_id, text, reply_markup=None):
172
- try:
173
- payload = {"chat_id": chat_id, "message_id": message_id, "text": text}
174
- if reply_markup:
175
- payload["reply_markup"] = reply_markup
176
- await client.post(f"{API_URL}/editMessageText", json=payload)
177
- except Exception:
178
- pass
179
-
180
- # ── Network helpers ───────────────────────────────────────────────────────────
181
-
182
- def get_lan_ip():
183
- try:
184
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
185
- s.connect(("8.8.8.8", 80))
186
- ip = s.getsockname()[0]
187
- if not ip.startswith("127."):
188
- return ip
189
- except OSError:
190
- pass
191
- try:
192
- hostname = socket.gethostname()
193
- for ip in socket.gethostbyname_ex(hostname)[2]:
194
- if not ip.startswith("127."):
195
- return ip
196
- except OSError:
197
- pass
198
- return "127.0.0.1"
199
-
200
- def get_web_url():
201
- if PUBLIC_WEB_URL:
202
- return PUBLIC_WEB_URL.rstrip("/")
203
- return f"http://{get_lan_ip()}:{SERVER_PORT}/?code={INVITE_CODE}"
204
-
205
- def get_graph_url():
206
- if PUBLIC_WEB_URL:
207
- return f"{PUBLIC_WEB_URL.rstrip('/')}/graph"
208
- return f"http://{get_lan_ip()}:{SERVER_PORT}/graph"
209
-
210
- # ── Broadcast (web → telegram mirror) ────────────────────────────────────────
211
-
212
- async def broadcast_web_chat(role, text):
213
- if not TOKEN:
214
- return
215
- chat_ids = load_chat_ids()
216
- if not chat_ids:
217
- return
218
- label = "사용자" if role == "user" else "Lattice AI"
219
- message = f"[Web] {label}\n{text}"
220
- async with httpx.AsyncClient() as client:
221
- for chat_id in chat_ids:
222
- await send_message(client, chat_id, message)
223
-
224
- # ── Polling ───────────────────────────────────────────────────────────────────
225
-
226
- async def get_updates(client, offset=None):
227
- url = f"{API_URL}/getUpdates?timeout=30"
228
- if offset:
229
- url += f"&offset={offset}"
230
- try:
231
- res = await client.get(url, timeout=35)
232
- return res.json()
233
- except Exception:
234
- return None
235
-
236
- # ── File download ─────────────────────────────────────────────────────────────
237
-
238
- async def download_telegram_file(client, file_id) -> bytes | None:
239
- try:
240
- res = await client.get(f"{API_URL}/getFile?file_id={file_id}")
241
- file_path = res.json().get("result", {}).get("file_path")
242
- if not file_path:
243
- return None
244
- dl = await client.get(f"https://api.telegram.org/file/bot{TOKEN}/{file_path}")
245
- return dl.content if dl.status_code == 200 else None
246
- except Exception as e:
247
- logger.error("파일 다운로드 실패: %s", safe_log_text(e))
248
- return None
249
-
250
- async def download_as_base64(client, file_id) -> str | None:
251
- data = await download_telegram_file(client, file_id)
252
- return base64.b64encode(data).decode() if data else None
253
-
254
- # ── Main menu ─────────────────────────────────────────────────────────────────
255
-
256
- MAIN_MENU = {
257
- "inline_keyboard": [
258
- [
259
- {"text": "📊 서버 상태", "callback_data": "cmd:status"},
260
- {"text": "🧠 현재 모델", "callback_data": "cmd:model"},
261
- ],
262
- [
263
- {"text": "🕸 Knowledge Graph", "callback_data": "cmd:graph"},
264
- {"text": "📸 스크린샷", "callback_data": "cmd:screenshot"},
265
- ],
266
- [
267
- {"text": "📜 최근 대화 5건", "callback_data": "cmd:history"},
268
- {"text": "🗑 기록 정리", "callback_data": "cmd:clear"},
269
- ],
270
- [
271
- {"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
272
- {"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
273
- ],
274
- ]
275
- }
276
-
277
- async def show_menu(client, chat_id):
278
- await send_message(client, chat_id, "📱 Lattice AI 원격 제어 메뉴입니다.", reply_markup=MAIN_MENU)
279
-
280
- # ── Server status ─────────────────────────────────────────────────────────────
281
-
282
- async def _mac_ram_used_gb() -> str:
283
- try:
284
- vm_proc = await asyncio.create_subprocess_exec(
285
- "vm_stat", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
286
- )
287
- vm_out, _ = await vm_proc.communicate()
288
- lines = vm_out.decode().splitlines()
289
-
290
- # Parse page size from header line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
291
- page_size = 4096
292
- if lines:
293
- import re
294
- m = re.search(r"page size of (\d+) bytes", lines[0])
295
- if m:
296
- page_size = int(m.group(1))
297
-
298
- stats = {}
299
- for line in lines[1:]:
300
- if ":" in line:
301
- k, _, v = line.partition(":")
302
- try:
303
- stats[k.strip()] = int(v.strip().rstrip(".")) * page_size
304
- except ValueError:
305
- pass
306
-
307
- used = stats.get("Pages active", 0) + stats.get("Pages wired down", 0)
308
-
309
- mem_proc = await asyncio.create_subprocess_exec(
310
- "sysctl", "-n", "hw.memsize", stdout=asyncio.subprocess.PIPE
311
- )
312
- mem_out, _ = await mem_proc.communicate()
313
- total = int(mem_out.strip())
314
- return f"{used/1e9:.1f} GB / {total/1e9:.0f} GB"
315
- except Exception:
316
- return "N/A"
317
-
318
- async def show_status(client, chat_id):
319
- await send_chat_action(client, chat_id, "typing")
320
- try:
321
- async with _server_client() as lc:
322
- res = await lc.get(STATUS_URL, timeout=5.0)
323
- data = res.json() if res.status_code == 200 else {}
324
- except Exception:
325
- data = {}
326
-
327
- ram = await _mac_ram_used_gb()
328
- model = data.get("loaded_model") or "없음"
329
- mode = data.get("mode") or "unknown"
330
- state = "🟢 온라인" if data.get("status") == "online" else "🔴 오프라인"
331
-
332
- text = (
333
- f"📊 Lattice AI 서버 상태\n"
334
- f"상태: {state}\n"
335
- f"모드: {mode}\n"
336
- f"모델: {model}\n"
337
- f"RAM: {ram}"
338
- )
339
- await send_message(client, chat_id, text)
340
-
341
- # ── Model info & unload ───────────────────────────────────────────────────────
342
-
343
- async def show_model_info(client, chat_id):
344
- await send_chat_action(client, chat_id, "typing")
345
- try:
346
- async with _server_client() as lc:
347
- res = await lc.get(MODELS_URL, timeout=5.0)
348
- data = res.json() if res.status_code == 200 else {}
349
- except Exception:
350
- data = {}
351
-
352
- current = data.get("current") or "없음"
353
- loaded = data.get("loaded") or []
354
- loaded_str = "\n".join(f" - {m}" for m in loaded) if loaded else " 없음"
355
- text = f"🧠 현재 모델: {current}\n\n로드된 모델:\n{loaded_str}"
356
-
357
- markup = None
358
- if loaded:
359
- markup = {
360
- "inline_keyboard": [
361
- [{"text": f"🗑 {m} 언로드", "callback_data": f"model:unload:{m}"}]
362
- for m in loaded
363
- ] + [[{"text": "↩ 메뉴로", "callback_data": "cmd:menu"}]]
364
- }
365
- await send_message(client, chat_id, text, reply_markup=markup)
366
-
367
- async def do_unload_model(client, chat_id, model_id: str = ""):
368
- await send_chat_action(client, chat_id, "typing")
369
- try:
370
- async with _server_client() as lc:
371
- if model_id:
372
- res = await lc.delete(f"{BASE_URL}/models/unload/{model_id}", timeout=15.0)
373
- else:
374
- # Unload all
375
- res = await lc.get(MODELS_URL, timeout=5.0)
376
- mdata = res.json() if res.status_code == 200 else {}
377
- for mid in mdata.get("loaded") or []:
378
- await lc.delete(f"{BASE_URL}/models/unload/{mid}", timeout=15.0)
379
- res = type("R", (), {"status_code": 200})()
380
- if res.status_code == 200:
381
- label = model_id or "모든 모델"
382
- await send_message(client, chat_id, f"✅ {label} 언로드 완료. RAM이 해제되었습니다.")
383
- else:
384
- await send_message(client, chat_id, f"언로드 실패 ({res.status_code})")
385
- except Exception as e:
386
- await send_message(client, chat_id, f"언로드 오류: {e}")
387
-
388
- # ── Knowledge Graph stats ─────────────────────────────────────────────────────
389
-
390
- async def show_graph_stats(client, chat_id):
391
- await send_chat_action(client, chat_id, "typing")
392
- try:
393
- async with _server_client() as lc:
394
- res = await lc.get(GRAPH_STATS_URL, timeout=5.0)
395
- data = res.json() if res.status_code == 200 else {}
396
- except Exception:
397
- data = {}
398
-
399
- nodes = data.get("nodes") or {}
400
- edges = data.get("edges") or {}
401
- total_nodes = sum(nodes.values())
402
- total_edges = sum(edges.values())
403
-
404
- node_lines = "\n".join(f" {t}: {c}" for t, c in sorted(nodes.items(), key=lambda x: -x[1])) or " 없음"
405
- edge_lines = "\n".join(f" {t}: {c}" for t, c in sorted(edges.items(), key=lambda x: -x[1])[:8]) or " 없음"
406
-
407
- text = (
408
- f"🕸 Knowledge Graph 통계\n\n"
409
- f"노드 총 {total_nodes}개:\n{node_lines}\n\n"
410
- f"엣지 총 {total_edges}개:\n{edge_lines}\n\n"
411
- f"그래프 보기: {get_graph_url()}"
412
- )
413
- markup = {
414
- "inline_keyboard": [[
415
- {"text": "🔗 그래프 열기", "url": get_graph_url()},
416
- {"text": "↩ 메뉴로", "callback_data": "cmd:menu"},
417
- ]]
418
- }
419
- await send_message(client, chat_id, text, reply_markup=markup)
420
-
421
- # ── Screenshot ────────────────────────────────────────────────────────────────
422
-
423
- async def take_screenshot(client, chat_id):
424
- await send_chat_action(client, chat_id, "upload_photo")
425
- tmp = Path(tempfile.mktemp(suffix=".jpg"))
426
- try:
427
- proc = await asyncio.create_subprocess_exec(
428
- "screencapture", "-x", str(tmp),
429
- stdout=asyncio.subprocess.DEVNULL,
430
- stderr=asyncio.subprocess.DEVNULL,
431
- )
432
- await asyncio.wait_for(proc.communicate(), timeout=10.0)
433
- if tmp.exists() and tmp.stat().st_size > 0:
434
- await send_photo(client, chat_id, tmp, caption="현재 화면입니다.")
435
- else:
436
- await send_message(client, chat_id, "스크린샷 파일이 생성되지 않았습니다. screencapture가 설치되어 있는지 확인하세요.")
437
- except asyncio.TimeoutError:
438
- await send_message(client, chat_id, "스크린샷 시간 초과")
439
- except FileNotFoundError:
440
- await send_message(client, chat_id, "screencapture 명령이 없습니다. macOS에서만 동작합니다.")
441
- except Exception as e:
442
- await send_message(client, chat_id, f"스크린샷 오류: {e}")
443
- finally:
444
- try:
445
- tmp.unlink(missing_ok=True)
446
- except Exception:
447
- pass
448
-
449
- # ── History ───────────────────────────────────────────────────────────────────
450
-
451
- async def show_history_summary(client, chat_id, n: int = 5):
452
- await send_chat_action(client, chat_id, "typing")
453
- try:
454
- async with _server_client() as lc:
455
- res = await lc.get(HISTORY_URL, timeout=10.0)
456
- items = res.json() if res.status_code == 200 else []
457
- except Exception:
458
- items = []
459
-
460
- if not items:
461
- await send_message(client, chat_id, "저장된 대화 기록이 없습니다.")
462
- return
463
-
464
- recent = [i for i in items if i.get("role") == "user"][-n:]
465
- lines = [f"📜 최근 사용자 메시지 {len(recent)}건\n"]
466
- for item in recent:
467
- ts = str(item.get("timestamp", ""))[:16]
468
- src = item.get("source", "web")
469
- content = str(item.get("content", ""))[:120].replace("\n", " ")
470
- lines.append(f"[{ts}] ({src}) {content}")
471
- await send_message(client, chat_id, "\n".join(lines))
472
-
473
- async def clear_server_history(client, chat_id, keep_last=0):
474
- try:
475
- async with _server_client() as lc:
476
- res = await lc.delete(HISTORY_URL, params={"keep_last": keep_last}, timeout=10.0)
477
- data = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
478
- if res.status_code == 200:
479
- await send_message(client, chat_id, f"대화 기록을 정리했습니다. 삭제 {data.get('removed', 0)}개, 유지 {data.get('kept', 0)}개.")
480
- else:
481
- await send_message(client, chat_id, f"대화 기록 정리 실패: {res.status_code}")
482
- except Exception as e:
483
- await send_message(client, chat_id, f"대화 기록 정리 오류: {e}")
484
-
485
- # ── Web UI link ───────────────────────────────────────────────────────────────
486
-
487
- async def send_web_link(client, chat_id):
488
- web_url = get_web_url()
489
- text = (
490
- "웹 UI 링크입니다.\n"
491
- f"{web_url}\n\n"
492
- "핸드폰이 Mac과 같은 Wi-Fi에 있어야 바로 열립니다. "
493
- "외부망에서 쓰려면 LATTICEAI_PUBLIC_URL에 터널 주소를 설정하세요."
494
- )
495
- payload = {
496
- "chat_id": chat_id,
497
- "text": text,
498
- "reply_markup": {
499
- "inline_keyboard": [[
500
- {"text": "Lattice AI Web 열기", "url": web_url},
501
- {"text": "Knowledge Graph", "url": get_graph_url()},
502
- ]]
503
- },
504
- }
505
- try:
506
- async with _server_client() as lc:
507
- await lc.post(f"{API_URL}/sendMessage", json=payload)
508
- except Exception as e:
509
- logger.error("웹 링크 전송 실패: %s", safe_log_text(e))
510
-
511
- # ── MCP tools ─────────────────────────────────────────────────────────────────
512
-
513
- async def send_mcp_tools(client, chat_id):
514
- try:
515
- async with _server_client() as lc:
516
- res = await lc.get(MCP_TOOLS_URL, timeout=10.0)
517
- if res.status_code != 200:
518
- await send_message(client, chat_id, f"MCP 도구 목록을 가져오지 못했습니다: {res.status_code}")
519
- return
520
- data = res.json()
521
- names = [tool["name"] for tool in data.get("tools", [])]
522
- await send_message(client, chat_id, "사용 가능한 MCP 도구:\n" + ("\n".join(f"- {n}" for n in names) or "없음"))
523
- except Exception as e:
524
- await send_message(client, chat_id, f"MCP 도구 조회 실패: {e}")
525
-
526
- # ── Document upload → knowledge graph ────────────────────────────────────────
527
-
528
- async def process_document_file(client, chat_id, file_id: str, filename: str, caption: str = ""):
529
- await send_chat_action(client, chat_id, "upload_document")
530
- raw = await download_telegram_file(client, file_id)
531
- if not raw:
532
- await send_message(client, chat_id, "파일 다운로드 실패")
533
- return
534
-
535
- suffix = Path(filename).suffix.lower()
536
- allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
537
- if suffix not in allowed:
538
- await send_message(client, chat_id,
539
- f"지원하지 않는 파일 형식입니다({suffix}). "
540
- f"지원 형식: {', '.join(sorted(allowed))}")
541
- return
542
-
543
- tmp = Path(tempfile.mktemp(suffix=suffix))
544
- try:
545
- tmp.write_bytes(raw)
546
- async with _server_client() as lc:
547
- with open(tmp, "rb") as f:
548
- res = await lc.post(
549
- UPLOAD_DOC_URL,
550
- files={"file": (filename, f)},
551
- timeout=60.0,
552
- )
553
- if res.status_code == 200:
554
- data = res.json()
555
- chars = data.get("chars") or len(raw)
556
- preview = str(data.get("preview") or "")[:300]
557
- kg = data.get("knowledge_graph") or {}
558
- node_id = kg.get("node_id", "")
559
- text = (
560
- f"✅ {filename} 수집 완료\n"
561
- f"크기: {len(raw) // 1024} KB | 문자: {chars}\n"
562
- f"노드: {node_id}\n"
563
- f"\n미리보기:\n{preview}"
564
- )
565
- await send_message(client, chat_id, text)
566
- else:
567
- err = res.json().get("detail") if res.headers.get("content-type", "").startswith("application/json") else res.text
568
- await send_message(client, chat_id, f"업로드 실패 ({res.status_code}): {err}")
569
- except Exception as e:
570
- await send_message(client, chat_id, f"문서 처리 오류: {e}")
571
- finally:
572
- tmp.unlink(missing_ok=True)
573
-
574
- # ── AI chat ───────────────────────────────────────────────────────────────────
575
-
576
- async def ask_ai(client, message, image_data=None, agent_mode=False,
577
- planning_model=None, executing_model=None, reviewing_model=None):
578
- try:
579
- if agent_mode and not image_data:
580
- url = AGENT_URL
581
- payload = {
582
- "message": message, "source": "telegram",
583
- "human_in_loop": True,
584
- "planning_model": planning_model,
585
- "executing_model": executing_model,
586
- "reviewing_model": reviewing_model,
587
- }
588
- else:
589
- url = CHAT_URL
590
- payload = {"message": message, "source": "telegram", "stream": False}
591
- if image_data:
592
- payload["image_data"] = image_data
593
- async with _server_client() as sc:
594
- res = await sc.post(url, json=payload, timeout=300.0)
595
- if res.status_code == 200:
596
- ct = res.headers.get("content-type", "")
597
- if "text/event-stream" in ct:
598
- text = ""
599
- for line in res.text.splitlines():
600
- if line.startswith("data:"):
601
- try:
602
- chunk = json.loads(line[5:].strip()).get("chunk", "")
603
- text += chunk
604
- except Exception:
605
- pass
606
- return {"response": text.strip() or "⚠️ 빈 응답"}
607
- return res.json()
608
- try:
609
- detail = res.json().get("detail", "")
610
- except Exception:
611
- detail = ""
612
- if res.status_code == 400 and "model" in detail.lower():
613
- return {"response": "⚠️ 로드된 모델이 없습니다. 먼저 /model 명령으로 모델을 선택해주세요."}
614
- return {"response": f"❌ 서버 에러 ({res.status_code}){': ' + detail if detail else ''}"}
615
- except Exception as e:
616
- return {"response": f"❌ 서버 연결 실패: {e}"}
617
-
618
- def resolve_workspace_file(relative_path):
619
- target = (AGENT_WORKSPACE / relative_path).resolve()
620
- if target != AGENT_WORKSPACE and AGENT_WORKSPACE not in target.parents:
621
- return None
622
- if not target.exists() or not target.is_file():
623
- return None
624
- if target.stat().st_size > MAX_TELEGRAM_FILE_BYTES:
625
- return None
626
- return target
627
-
628
- def collect_generated_files(agent_data):
629
- files, seen = [], set()
630
- for step in agent_data.get("steps", []):
631
- if step.get("action") not in {"write_file", "create_docx", "create_xlsx", "create_pptx", "create_pdf"}:
632
- continue
633
- path = (step.get("result") or {}).get("path") or (step.get("args") or {}).get("path")
634
- if not path or path in seen:
635
- continue
636
- target = resolve_workspace_file(path)
637
- if target:
638
- seen.add(path)
639
- files.append((path, target))
640
- return files
641
-
642
- def collect_preview_urls(agent_data):
643
- urls, seen = [], set()
644
- for step in agent_data.get("steps", []):
645
- if step.get("action") != "preview_url":
646
- continue
647
- result = step.get("result") or {}
648
- local_url = result.get("local_url")
649
- if not local_url or local_url in seen:
650
- continue
651
- phone_url = local_url.replace("http://127.0.0.1:4825", f"http://{get_lan_ip()}:{SERVER_PORT}")
652
- seen.add(local_url)
653
- urls.append((result.get("path") or "preview", phone_url))
654
- return urls
655
-
656
- async def send_preview_links(client, chat_id, preview_urls):
657
- if not preview_urls:
658
- return
659
- lines = ["미리보기 링크 (Mac과 같은 Wi-Fi 필요):"]
660
- keyboard = []
661
- for label, url in preview_urls:
662
- lines.append(f"- {label}: {url}")
663
- keyboard.append([{"text": f"{label} 열기"[:64], "url": url}])
664
- await send_message(client, chat_id, "\n".join(lines), reply_markup={"inline_keyboard": keyboard[:8]})
665
-
666
- async def send_generated_files(client, chat_id, generated_files):
667
- if not generated_files:
668
- return
669
- if len(generated_files) == 1:
670
- path, fpath = generated_files[0]
671
- await send_document(client, chat_id, fpath, caption=f"생성 파일: {path}")
672
- return
673
- with tempfile.NamedTemporaryFile(prefix="ltcai-", suffix=".zip", delete=False) as tmp:
674
- zip_path = Path(tmp.name)
675
- try:
676
- with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
677
- for rel, fpath in generated_files:
678
- zf.write(fpath, arcname=rel)
679
- if zip_path.stat().st_size <= MAX_TELEGRAM_FILE_BYTES:
680
- await send_document(client, chat_id, zip_path,
681
- caption=f"생성 파일 {len(generated_files)}개", filename="ltcai-files.zip")
682
- else:
683
- await send_message(client, chat_id, "생성 파일이 너무 커서 전송할 수 없습니다.")
684
- finally:
685
- zip_path.unlink(missing_ok=True)
686
-
687
- # ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
688
-
689
- async def send_plan_for_approval(client, chat_id, data: dict) -> None:
690
- """Show the agent plan to the user and present Done/Cancel buttons."""
691
- context_id = data.get("context_id", "")
692
- plan = data.get("plan", {})
693
- goal = plan.get("goal", "")
694
- steps = plan.get("steps", [])
695
- p_model = data.get("planning_model", "current")
696
- e_model = data.get("executing_model", "current")
697
- r_model = data.get("reviewing_model", "current")
698
-
699
- lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
700
- if goal:
701
- lines.append(f"*목표:* {goal}\n")
702
- for i, step in enumerate(steps, 1):
703
- desc = step.get("description") or step.get("action") or str(step)
704
- lines.append(f"{i}. {desc}")
705
- lines.append(f"\n🧠 플래닝: `{p_model}`")
706
- lines.append(f"⚙️ 실행: `{e_model}`")
707
- lines.append(f"🔍 검토: `{r_model}`")
708
-
709
- _bot_pending_plans[context_id] = {
710
- "chat_id": chat_id,
711
- "executing_model": data.get("executing_model"),
712
- "reviewing_model": data.get("reviewing_model"),
713
- }
714
-
715
- keyboard = {"inline_keyboard": [[
716
- {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{context_id}"},
717
- {"text": "❌ 취소", "callback_data": f"plan:cancel:{context_id}"},
718
- ]]}
719
- await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
720
-
721
-
722
- async def handle_plan_callback(client, chat_id, data: str) -> None:
723
- """Handle Done/Cancel callback from plan approval buttons."""
724
- parts = data.split(":", 2)
725
- if len(parts) != 3:
726
- return
727
- _, action, context_id = parts
728
- pending = _bot_pending_plans.pop(context_id, None)
729
-
730
- if action == "cancel" or not pending:
731
- await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
732
- return
733
-
734
- await send_message(client, chat_id, "⚙️ 실행 중입니다. 잠시 기다려주세요...")
735
- await send_chat_action(client, chat_id, "typing")
736
-
737
- try:
738
- async with _server_client() as sc:
739
- res = await sc.post(AGENT_RESUME_URL, json={
740
- "context_id": context_id,
741
- "approved": True,
742
- "executing_model": pending.get("executing_model"),
743
- "reviewing_model": pending.get("reviewing_model"),
744
- }, timeout=300.0)
745
- data_r = res.json() if res.status_code == 200 else {}
746
- ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
747
- await send_message(client, chat_id, str(ans))
748
- if isinstance(data_r, dict):
749
- await send_generated_files(client, chat_id, collect_generated_files(data_r))
750
- await send_preview_links(client, chat_id, collect_preview_urls(data_r))
751
- except Exception as e:
752
- await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
753
-
754
-
755
- # ── AI request task ───────────────────────────────────────────────────────────
756
-
757
- async def process_ai_request(client, chat_id, user_text, image_data=None):
758
- try:
759
- await send_chat_action(client, chat_id, "upload_photo" if image_data else "typing")
760
- logger.info("ask_ai 호출 시작: chat_id=%s text=%r", chat_id, safe_log_text(user_text[:30]))
761
- data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
762
- logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
763
-
764
- # Human-in-the-loop: show plan and wait for approval
765
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
766
- await send_plan_for_approval(client, chat_id, data)
767
- return
768
-
769
- ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
770
- if not ans or not str(ans).strip():
771
- ans = "⚠️ AI가 답변을 생성하지 못했습니다."
772
- await send_message(client, chat_id, str(ans))
773
- if not image_data and isinstance(data, dict):
774
- await send_generated_files(client, chat_id, collect_generated_files(data))
775
- await send_preview_links(client, chat_id, collect_preview_urls(data))
776
- except Exception as e:
777
- logger.error("process_ai_request 실패 (chat_id=%s): %s", chat_id, safe_log_text(e), exc_info=True)
778
- try:
779
- await send_message(client, chat_id, "⚠️ 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
780
- except Exception:
781
- pass
782
-
783
- # ── Command dispatch ──────────────────────────────────────────────────────────
784
-
785
- HELP_TEXT = """\
786
- 🧠 Lattice AI 원격 제어 명령어
787
-
788
- /menu — 메인 메뉴 (인라인 키보드)
789
- /status — 서버 상태 및 메모리
790
- /model — 현재 모델 + 언로드 버튼
791
- /unload — 모든 모델 언로드 (RAM 해제)
792
- /graph — Knowledge Graph 통계
793
- /ss 또는 /screenshot — 현재 화면 캡처
794
- /history [n] — 최근 대화 n건 (기본 5)
795
- /clear [n] — 기록 정리 (마지막 n건 유지)
796
- /web — 웹 UI 링크
797
- /mcp — MCP 도구 목록
798
- /help — 이 도움말
799
-
800
- /agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
801
- /agent <작업> --exec <모델> --review <모델> — 실행/검토 LLM 지정
802
-
803
- 일반 텍스트 → AI에게 질문
804
- 사진 전송 → AI 이미지 분석
805
- 문서 전송(PDF, DOCX, XLSX, PPTX, TXT, CSV) → Knowledge Graph 수집
806
- """
807
-
808
- async def handle_command(client, chat_id, command: str, args: str):
809
- cmd = command.lower().lstrip("/").split("@")[0]
810
-
811
- if cmd == "start":
812
- await send_message(client, chat_id, "🧠 Lattice AI 원격 제어 준비 완료!")
813
- await show_menu(client, chat_id)
814
- elif cmd == "menu":
815
- await show_menu(client, chat_id)
816
- elif cmd == "status":
817
- await show_status(client, chat_id)
818
- elif cmd == "model":
819
- await show_model_info(client, chat_id)
820
- elif cmd == "unload":
821
- await do_unload_model(client, chat_id)
822
- elif cmd == "graph":
823
- await show_graph_stats(client, chat_id)
824
- elif cmd in {"ss", "screenshot"}:
825
- await take_screenshot(client, chat_id)
826
- elif cmd == "history":
827
- n = int(args.strip()) if args.strip().isdigit() else 5
828
- await show_history_summary(client, chat_id, n)
829
- elif cmd in {"clear", "clear_history", "forget"}:
830
- keep = int(args.strip()) if args.strip().isdigit() else 0
831
- await clear_server_history(client, chat_id, keep)
832
- elif cmd == "web":
833
- await send_web_link(client, chat_id)
834
- elif cmd == "mcp":
835
- await send_mcp_tools(client, chat_id)
836
- elif cmd in {"help", "h"}:
837
- await send_message(client, chat_id, HELP_TEXT)
838
- elif cmd == "agent":
839
- if not args:
840
- await send_message(client, chat_id, "사용법: /agent <작업 내용>\n예: /agent 쇼핑몰 메인 페이지 HTML 만들어줘\n\n특정 AI 지정:\n/agent <작업> --exec openai/gpt-4o --review together:Qwen/Qwen3-VL-32B-Instruct")
841
- return
842
- # Parse optional --exec / --review flags
843
- exec_model = reviewing_model = None
844
- task_text = args
845
- import re as _re
846
- em = _re.search(r'--exec\s+(\S+)', args)
847
- rm = _re.search(r'--review\s+(\S+)', args)
848
- if em:
849
- exec_model = em.group(1)
850
- task_text = task_text.replace(em.group(0), "").strip()
851
- if rm:
852
- reviewing_model = rm.group(1)
853
- task_text = task_text.replace(rm.group(0), "").strip()
854
- await send_chat_action(client, chat_id, "typing")
855
- data = await ask_ai(client, task_text, agent_mode=True,
856
- executing_model=exec_model, reviewing_model=reviewing_model)
857
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
858
- await send_plan_for_approval(client, chat_id, data)
859
- else:
860
- ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
861
- await send_message(client, chat_id, ans)
862
- else:
863
- await send_message(client, chat_id, f"알 수 없는 명령어: /{cmd}\n/help 로 명령어 목록을 확인하세요.")
864
-
865
- # ── Callback query handler ────────────────────────────────────────────────────
866
-
867
- async def handle_callback_query(client, callback_query):
868
- cq_id = callback_query["id"]
869
- chat_id = callback_query["message"]["chat"]["id"]
870
- data = callback_query.get("data", "")
871
-
872
- await answer_callback(client, cq_id)
873
-
874
- if data == "cmd:status":
875
- await show_status(client, chat_id)
876
- elif data == "cmd:model":
877
- await show_model_info(client, chat_id)
878
- elif data == "cmd:graph":
879
- await show_graph_stats(client, chat_id)
880
- elif data == "cmd:screenshot":
881
- await take_screenshot(client, chat_id)
882
- elif data == "cmd:history":
883
- await show_history_summary(client, chat_id, 5)
884
- elif data == "cmd:clear":
885
- await clear_server_history(client, chat_id, 0)
886
- elif data == "cmd:web":
887
- await send_web_link(client, chat_id)
888
- elif data == "cmd:mcp":
889
- await send_mcp_tools(client, chat_id)
890
- elif data == "cmd:menu":
891
- await show_menu(client, chat_id)
892
- elif data.startswith("model:unload:"):
893
- model_id = data[len("model:unload:"):]
894
- await do_unload_model(client, chat_id, model_id)
895
- elif data.startswith("plan:"):
896
- task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
897
- task.add_done_callback(_log_task_exception)
898
-
899
- # ── Main loop ─────────────────────────────────────────────────────────────────
900
-
901
- async def run_bot():
902
- if not TOKEN:
903
- logger.warning("LATTICEAI_TELEGRAM_BOT_TOKEN이 설정되지 않아 텔레그램 봇을 시작하지 않습니다.")
904
- return
905
-
906
- logger.info("🚀 비동기 텔레그램 봇 모드 시작!")
907
- last_update_id = None
908
- retry_delay = 1
909
-
910
- async with httpx.AsyncClient() as client:
911
- while True:
912
- try:
913
- updates = await get_updates(client, last_update_id)
914
- retry_delay = 1
915
- except Exception as e:
916
- logger.error("get_updates 실패: %s", safe_log_text(e))
917
- await asyncio.sleep(min(retry_delay, 30))
918
- retry_delay = min(retry_delay * 2, 30)
919
- continue
920
-
921
- if not (updates and updates.get("ok")):
922
- await asyncio.sleep(0.5)
923
- continue
924
-
925
- for update in updates.get("result", []):
926
- try:
927
- last_update_id = update.get("update_id") + 1
928
-
929
- # ── Callback query (inline button press) ──────────────────
930
- if "callback_query" in update:
931
- task = asyncio.create_task(handle_callback_query(client, update["callback_query"]))
932
- task.add_done_callback(_log_task_exception)
933
- continue
934
-
935
- if "message" not in update:
936
- continue
937
-
938
- msg = update["message"]
939
- chat_id = msg["chat"]["id"]
940
- register_chat_id(chat_id)
941
- text = msg.get("text", "")
942
- caption = msg.get("caption", "")
943
-
944
- # ── Photo → vision AI ─────────────────────────────────────
945
- if "photo" in msg:
946
- file_id = msg["photo"][-1]["file_id"]
947
- await send_message(client, chat_id, "📸 사진을 받았습니다. 분석을 시작합니다...")
948
- image_data = await download_as_base64(client, file_id)
949
- prompt = caption or text or "이 이미지를 분석해줘."
950
- task = asyncio.create_task(process_ai_request(client, chat_id, prompt, image_data))
951
- task.add_done_callback(_log_task_exception)
952
- continue
953
-
954
- # ── Document ──────────────────────────────────────────────
955
- if "document" in msg:
956
- doc = msg["document"]
957
- mime = doc.get("mime_type", "")
958
- filename = doc.get("file_name", "file")
959
- if mime.startswith("image/"):
960
- image_data = await download_as_base64(client, doc["file_id"])
961
- prompt = caption or text or "이 이미지를 분석해줘."
962
- task = asyncio.create_task(process_ai_request(client, chat_id, prompt, image_data))
963
- else:
964
- await send_message(client, chat_id, f"📄 {filename} 을 Knowledge Graph에 수집합니다...")
965
- task = asyncio.create_task(
966
- process_document_file(client, chat_id, doc["file_id"], filename, caption)
967
- )
968
- task.add_done_callback(_log_task_exception)
969
- continue
970
-
971
- # ── Voice / audio ─────────────────────────────────────────
972
- if "voice" in msg or "audio" in msg:
973
- await send_message(
974
- client, chat_id,
975
- "🎤 음성 메시지를 받았습니다. 현재 음성 인식(Whisper)이 설정되어 있지 않습니다.\n"
976
- "텍스트로 질문을 보내주세요."
977
- )
978
- continue
979
-
980
- if not text:
981
- continue
982
-
983
- # ── Commands ──────────────────────────────────────────────
984
- if text.startswith("/"):
985
- parts = text.split(None, 1)
986
- command = parts[0]
987
- args = parts[1] if len(parts) > 1 else ""
988
- task = asyncio.create_task(handle_command(client, chat_id, command, args))
989
- task.add_done_callback(_log_task_exception)
990
- continue
991
-
992
- # ── Plain text → AI ───────────────────────────────────────
993
- task = asyncio.create_task(process_ai_request(client, chat_id, text))
994
- task.add_done_callback(_log_task_exception)
995
-
996
- except Exception as e:
997
- logger.error("업데이트 처리 중 예외: %s", safe_log_text(e))
998
-
999
- await asyncio.sleep(0.5)
1000
-
1001
- def _log_task_exception(task):
1002
- if not task.cancelled() and task.exception():
1003
- logger.error("백그라운드 태스크 예외: %s", safe_log_text(task.exception()))
1004
5
 
1005
6
  if __name__ == "__main__":
7
+ import asyncio
8
+
1006
9
  try:
1007
- asyncio.run(run_bot())
10
+ asyncio.run(_impl.run_bot())
1008
11
  except KeyboardInterrupt:
1009
12
  pass
13
+ else:
14
+ import sys
15
+
16
+ sys.modules[__name__] = _impl