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.
- package/README.md +33 -38
- package/docs/CHANGELOG.md +33 -1
- package/frontend/src/App.tsx +3 -1286
- package/frontend/src/components/LanguageSwitcher.tsx +23 -0
- package/frontend/src/components/ProductFlow.tsx +32 -669
- package/frontend/src/components/onboarding/ProductFlowScreens.tsx +688 -0
- package/frontend/src/features/admin/AdminConsole.tsx +294 -0
- package/frontend/src/features/brain/BrainHome.tsx +999 -0
- package/frontend/src/features/brain/brainData.ts +98 -0
- package/frontend/src/features/brain/graphLayout.ts +26 -0
- package/frontend/src/features/brain/types.ts +44 -0
- package/frontend/src/i18n.ts +198 -0
- package/frontend/src/styles.css +220 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/tools.py +50 -23
- package/latticeai/app_factory.py +36 -45
- package/latticeai/cli/entrypoint.py +283 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/__init__.py +0 -0
- package/latticeai/integrations/telegram_bot.py +1009 -0
- package/latticeai/runtime/lifespan_runtime.py +1 -1
- package/latticeai/runtime/platform_runtime_wiring.py +87 -0
- package/latticeai/runtime/router_registration.py +49 -30
- package/latticeai/services/p_reinforce.py +258 -0
- package/latticeai/services/router_context.py +52 -0
- package/ltcai_cli.py +7 -279
- package/p_reinforce.py +4 -255
- package/package.json +2 -1
- package/scripts/release_smoke.py +133 -0
- package/scripts/wheel_smoke.py +4 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/{index-B744yblP.css → index-B2-1Gm0q.css} +1 -1
- package/static/app/assets/index-D91Rz5--.js +16 -0
- package/static/app/assets/index-D91Rz5--.js.map +1 -0
- package/static/app/index.html +2 -2
- package/telegram_bot.py +9 -1002
- package/static/app/assets/index-DYaUKNfl.js +0 -16
- package/static/app/assets/index-DYaUKNfl.js.map +0 -1
package/telegram_bot.py
CHANGED
|
@@ -1,1009 +1,16 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|