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