homelab-brain/homelab-ai-bot/model_library_routes.py
2026-04-09 17:15:36 +02:00

345 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Model Library Integration — Telegram-Buttons, Befehle, Watcher-Webhook.
Wird von telegram_bot.py eingebunden:
import model_library_routes
model_library_routes.register(app, bot_token, chat_id)
"""
import json
import logging
import threading
import time
import requests
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes
log = logging.getLogger(__name__)
MODEL_LIBRARY_URL = "http://100.116.108.62:8090"
_CHAT_ID: int | None = None
_APP = None
_TG_TOKEN: str | None = None
_PENDING_FILE = "/var/cache/model-library-pending.json"
# --- Persistence for pending notifications ---
def _load_pending() -> list[dict]:
try:
with open(_PENDING_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_pending(items: list[dict]):
with open(_PENDING_FILE, "w") as f:
json.dump(items, f, ensure_ascii=False, indent=2)
def _remove_pending(repo_id: str, filename: str):
items = [
m for m in _load_pending()
if not (m.get("repo_id") == repo_id and m.get("filename") == filename)
]
_save_pending(items)
# --- Telegram handlers ---
async def cmd_modelle(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Zeigt heruntergeladene Modelle aus dem Katalog."""
try:
r = requests.get(f"{MODEL_LIBRARY_URL}/api/catalog", timeout=10)
r.raise_for_status()
catalog = r.json()
except Exception as e:
await update.message.reply_text(f"Fehler beim Abruf: {e}")
return
models = catalog.get("models", [])
if not models:
await update.message.reply_text("📂 Bibliothek ist leer.")
return
try:
sr = requests.get(f"{MODEL_LIBRARY_URL}/api/storage", timeout=5)
storage = sr.json()
header = (
f"💾 {storage['used_gb']:.0f} / {storage['total_gb']:.0f} GB "
f"({storage['percent_used']:.0f}%) belegt\n\n"
)
except Exception:
header = ""
cat_groups: dict[str, list] = {}
for m in models:
cat_groups.setdefault(m.get("category", "llm"), []).append(m)
cat_icons = {
"llm": "🧠", "code": "💻", "vision": "👁", "embedding": "📐",
"audio": "🎵", "image-gen": "🎨",
}
lines = [f"📚 *KI-Modell-Bibliothek* ({len(models)} Modelle)\n{header}"]
for cat, items in sorted(cat_groups.items()):
icon = cat_icons.get(cat, "📦")
lines.append(f"{icon} *{cat.upper()}*")
for m in sorted(items, key=lambda x: x["filename"]):
q = m.get("quant", "?")
sz = m.get("size_gb", 0)
lines.append(f" • `{m['filename']}` ({q}, {sz:.1f} GB)")
lines.append("")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
async def cmd_watchlist(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Zeigt die aktuelle Model-Watchlist."""
try:
r = requests.get(f"{MODEL_LIBRARY_URL}/api/watchlist", timeout=10)
r.raise_for_status()
data = r.json()
except Exception as e:
await update.message.reply_text(f"Fehler beim Abruf: {e}")
return
authors = data.get("authors", [])
if not authors:
await update.message.reply_text("Watchlist ist leer.")
return
lines = ["👀 *Model-Watchlist*\n"]
for a in authors:
name = a.get("name", "?")
comment = a.get("comment", "")
lines.append(f" • *{name}* — {comment}")
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
def _resolve_callback(data: str) -> tuple[str, str] | None:
"""Löst Callback-Data auf — direkt (repo|file) oder über Index (idx_N)."""
prefix = data[:9]
payload = data[9:]
if payload.startswith("idx_"):
try:
idx = int(payload[4:])
pending = _load_pending()
if 0 <= idx < len(pending):
m = pending[idx]
return m.get("repo_id"), m.get("filename")
except (ValueError, IndexError):
pass
return None
if "|" in payload:
return tuple(payload.split("|", 1))
return None
async def handle_model_callback(data: str, query) -> bool:
"""Verarbeitet model_dl_* und model_ig_* Callbacks. Gibt True zurück wenn verarbeitet."""
if data.startswith("model_dl_"):
resolved = _resolve_callback(data)
if not resolved:
await query.edit_message_text(query.message.text + "\n\n⚠️ Modell nicht mehr verfügbar")
return True
repo_id, filename = resolved
await query.edit_message_text(
query.message.text + f"\n\n⏳ Download gestartet: `{filename}`",
parse_mode="Markdown",
)
try:
r = requests.post(
f"{MODEL_LIBRARY_URL}/api/download",
json={"repo_id": repo_id, "filename": filename},
timeout=15,
)
result = r.json()
if result.get("status") == "started":
await query.edit_message_text(
query.message.text.split("\n\n")[0]
+ f"\n\n✅ Download läuft: `{filename}`",
parse_mode="Markdown",
)
else:
err = result.get("error", "Unbekannter Fehler")
await query.edit_message_text(
query.message.text.split("\n\n")[0]
+ f"\n\n {err}",
parse_mode="Markdown",
)
except Exception as e:
log.exception("Model download trigger failed")
await query.edit_message_text(
query.message.text.split("\n\n")[0]
+ f"\n\n❌ Fehler: {e}",
parse_mode="Markdown",
)
_remove_pending(repo_id, filename)
return True
if data.startswith("model_ig_"):
resolved = _resolve_callback(data)
if not resolved:
await query.edit_message_text(query.message.text + "\n\n⚠️ Modell nicht mehr verfügbar")
return True
repo_id, filename = resolved
try:
requests.post(
f"{MODEL_LIBRARY_URL}/api/ignore",
json={"repo_id": repo_id, "filename": filename},
timeout=10,
)
except Exception:
pass
await query.edit_message_text(
query.message.text + "\n\n🚫 Ignoriert"
)
_remove_pending(repo_id, filename)
return True
return False
# --- Webhook: Watcher meldet neue Modelle ---
def _notify_new_models_sync(models: list[dict]):
"""Sendet Telegram-Nachrichten mit Inline-Buttons fuer jedes neue Modell.
Nutzt die Telegram HTTP API direkt (nicht python-telegram-bot),
da wir in einem Worker-Thread ohne asyncio-Loop laufen.
"""
if not _TG_TOKEN or not _CHAT_ID:
log.warning("Bot-Token oder Chat-ID nicht konfiguriert")
return
MAX_PER_BATCH = 10
if len(models) > MAX_PER_BATCH:
log.warning("Benachrichtigungen begrenzt: %d -> %d", len(models), MAX_PER_BATCH)
models = models[:MAX_PER_BATCH]
pending = _load_pending()
sent = 0
for m in models:
repo_id = m.get("repo_id", "?")
filename = m.get("filename", "?")
size_gb = m.get("size_gb", 0)
quant = m.get("quant", "?")
already = any(
p.get("repo_id") == repo_id and p.get("filename") == filename
for p in pending
)
if already:
continue
cb_dl = f"model_dl_{repo_id}|{filename}"
cb_ig = f"model_ig_{repo_id}|{filename}"
used_index = False
if len(cb_dl.encode()) > 64 or len(cb_ig.encode()) > 64:
pending.append(m)
idx = len(pending) - 1
_save_pending(pending)
used_index = True
cb_dl = f"model_dl_idx_{idx}"
cb_ig = f"model_ig_idx_{idx}"
safe_repo = repo_id.replace("_", "\\_")
text = (
f"🆕 *Neues KI-Modell verfügbar*\n\n"
f"📦 `{filename}`\n"
f"🏷 Repo: {safe_repo}\n"
f"📏 {size_gb:.1f} GB | Quant: {quant}"
)
keyboard = {
"inline_keyboard": [[
{"text": "✅ Herunterladen", "callback_data": cb_dl},
{"text": "🚫 Ignorieren", "callback_data": cb_ig},
]]
}
try:
resp = requests.post(
f"https://api.telegram.org/bot{_TG_TOKEN}/sendMessage",
json={
"chat_id": _CHAT_ID,
"text": text,
"parse_mode": "Markdown",
"reply_markup": keyboard,
},
timeout=15,
)
if resp.ok:
log.info("Modell-Benachrichtigung gesendet: %s", filename)
sent += 1
else:
log.error("Telegram API Fehler: %s %s", resp.status_code, resp.text[:200])
except Exception:
log.exception("Fehler beim Senden der Modell-Benachrichtigung: %s", filename)
if not used_index:
pending.append(m)
_save_pending(pending)
time.sleep(1.5)
def start_webhook_listener():
"""Startet einen kleinen Flask-Server (Port 8767) fuer Watcher-Webhooks."""
from flask import Flask, request as freq, jsonify
webhook_app = Flask("model-library-webhook")
webhook_app.logger.setLevel(logging.WARNING)
werkzeug_log = logging.getLogger("werkzeug")
werkzeug_log.setLevel(logging.WARNING)
@webhook_app.route("/api/notify_new_models", methods=["POST"])
def notify():
data = freq.get_json(silent=True)
if not data:
return jsonify({"error": "no data"}), 400
models = data.get("models", [])
if models:
threading.Thread(
target=_notify_new_models_sync, args=(models,), daemon=True
).start()
return jsonify({"status": "ok", "queued": len(models)})
@webhook_app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
t = threading.Thread(
target=lambda: webhook_app.run(host="0.0.0.0", port=8767, use_reloader=False),
daemon=True,
)
t.start()
log.info("Model-Library Webhook-Listener auf Port 8767 gestartet")
# --- Registration ---
def register(app, chat_id: int | None, token: str | None = None):
"""Wird von telegram_bot.py aufgerufen."""
global _CHAT_ID, _APP, _TG_TOKEN
_CHAT_ID = chat_id
_APP = app
_TG_TOKEN = token
from telegram.ext import CommandHandler
app.add_handler(CommandHandler("modelle", cmd_modelle))
app.add_handler(CommandHandler("watchlist", cmd_watchlist))
start_webhook_listener()
log.info("Model-Library Routes registriert (/modelle, /watchlist, Webhook :8767)")