diff --git a/.env b/.env index f460b64..adce938 100644 --- a/.env +++ b/.env @@ -4,6 +4,7 @@ AI_PROVIDER=google # --- API Keys --- GOOGLE_API_KEY=dein-google-key-hier OPENAI_API_KEY=dein-openai-key-hier +NIDIA_API_KEY=dein-nvidia-key-hier # --- Modelle --- GOOGLE_MODEL=gemini-2.5-flash @@ -13,6 +14,6 @@ OLLAMA_MODEL=llama3 # --- Lokale KI (Ollama) --- OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 -# --- System Prompt --- -# WICHTIG: Den Platzhalter {node_info} nicht entfernen! -SYSTEM_PROMPT="Du bist der Pi-Orchestrator KI-Assistent. Deine Aufgabe ist es, Befehle auf Raspberry Pis auszuführen. Hier sind die verbundenen Nodes:\n{node_info}\nWenn der Nutzer dich bittet etwas zu tun, frage kurz nach ob du es ausführen sollst. Erst nach Bestätigung fügst du die Befehle im Format befehl hinzu." +TELEGRAM_BOT_TOKEN=dein-telegram-bot-token +ALLOWED_TELEGRAM_USER_ID=deine-telegram-id + diff --git a/README.md b/README.md index 7120df9..fe58062 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# PiDoBot +# J.A.R.V.I.S. - AI Setup: -curl -sSL https://git.pi-farm.de/pi-farm/PiDoBot/raw/branch/main/setup.sh | bash +curl -sSL https://git.pi-farm.de/pi-farm/PiDoBot/raw/branch/dev/setup.sh | bash diff --git a/main.py b/main.py index d7fe784..aa9964e 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,11 @@ import asyncio import openai import re import httpx +import struct +import termios +from telegram import Update +from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters +from telegram.error import InvalidToken from google import genai from google.genai import types import json @@ -16,7 +21,7 @@ from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from dotenv import load_dotenv, set_key -# Lade Umgebungsvariablen aus der .env Datei +# Lade Umgebungsvariablen load_dotenv() app = FastAPI() @@ -28,70 +33,102 @@ SSH_KEY = os.path.expanduser("~/.ssh/id_rsa") DB_PATH = "cluster.db" chat_history = [] PROMPT_FILE = "system_prompt.txt" -ENV_FILE = os.path.join(os.path.dirname(__file__), ".env") # NEU -# --- KI KONFIGURATION (Werte aus .env laden) --- +ENV_FILE = os.path.join(os.path.dirname(__file__), ".env") + +# KI KONFIGURATION AI_PROVIDER = os.getenv("AI_PROVIDER", "google").lower() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "") +NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "") OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434/v1") -# Modelle aus .env laden (mit Standardwerten als Fallback) -GOOGLE_MODEL = os.getenv("GOOGLE_MODEL", "gemini-2.5-flash") +GOOGLE_MODEL = os.getenv("GOOGLE_MODEL", "gemini-2.0-flash") OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3") +NVIDIA_MODEL = os.getenv("NVIDIA_MODEL", "moonshotai/kimi-k2.5") + +# Telegram Bot Konfiguration +TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +ALLOWED_ID = os.getenv("ALLOWED_TELEGRAM_USER_ID", "") +telegram_app = None + +# --- DATENBANK INITIALISIERUNG (ERWEITERT) --- +def init_db(): + conn = sqlite3.connect(DB_PATH) + # Spalten erweitert um sudo_password, os, arch, docker_installed + conn.execute(''' + CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + ip TEXT UNIQUE, + user TEXT, + sudo_password TEXT, + os TEXT DEFAULT 'Unbekannt', + arch TEXT DEFAULT 'Unbekannt', + docker_installed INTEGER DEFAULT 0, + status TEXT + ) + ''') + conn.commit() + conn.close() + +init_db() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn def get_system_prompt(): - # 1. Node Info aus DB holen conn = get_db() nodes = conn.execute('SELECT * FROM nodes').fetchall() conn.close() node_info = "" for n in nodes: - node_info += f"- Name: {n['name']}, IP: {n['ip']}, User: {n['user']}\n" + docker_str = "Ja" if n['docker_installed'] else "Nein" + node_info += f"- Name: {n['name']}, IP: {n['ip']}, User: {n['user']}, OS: {n['os']}, Arch: {n['arch']}, Docker: {docker_str}\n" - # 2. Versuche den Prompt aus der Datei zu laden if os.path.exists(PROMPT_FILE): with open(PROMPT_FILE, "r", encoding="utf-8") as f: template = f.read() else: - # Fallback falls Datei fehlt - template = "Du bist ein Helfer. Nodes:\n{node_info}\nNutze cmd" - print(f"⚠️ Warnung: {PROMPT_FILE} nicht gefunden. Nutze Fallback.") + template = "Du bist ein Cluster-Orchestrator. Nodes:\n{node_info}\nBefehle via cmd" + print(f"⚠️ Warnung: {PROMPT_FILE} fehlt.") return template.replace("{node_info}", node_info) - + # --- KI FUNKTIONEN --- async def get_ai_response(user_input, system_prompt): global chat_history - - # 1. Die neue User-Nachricht dem Gedächtnis hinzufügen chat_history.append({"role": "user", "content": user_input}) - - # 2. Gedächtnis auf die letzten 30 Nachrichten begrenzen chat_history = chat_history[-30:] - ai_msg = "" try: - if AI_PROVIDER == "openai" or AI_PROVIDER == "ollama": + # Kombinierte Logik für OpenAI, Ollama und NVIDIA (alle nutzen das OpenAI SDK) + if AI_PROVIDER in ["openai", "ollama", "nvidia"]: messages = [{"role": "system", "content": system_prompt}] + chat_history - # Sicherstellen, dass die URL für Ollama korrekt endet if AI_PROVIDER == "ollama": url = OLLAMA_BASE_URL if not url.endswith('/v1') and not url.endswith('/v1/'): url = url.rstrip('/') + '/v1' key = "ollama" model_to_use = OLLAMA_MODEL - else: - url = None # Benutzt Standard OpenAI URL + elif AI_PROVIDER == "nvidia": + url = "https://integrate.api.nvidia.com/v1" + key = NVIDIA_API_KEY + model_to_use = NVIDIA_MODEL + else: # openai + url = None key = OPENAI_API_KEY model_to_use = OPENAI_MODEL - client = openai.OpenAI(base_url=url, api_key=key) - response = client.chat.completions.create( + # WICHTIG: Hier .AsyncOpenAI nutzen, da die Funktion async ist + client = openai.AsyncOpenAI(base_url=url, api_key=key) + response = await client.chat.completions.create( model=model_to_use, messages=messages ) @@ -134,64 +171,210 @@ async def get_ai_response(user_input, system_prompt): return ai_msg -# --- DATENBANK INITIALISIERUNG --- -def init_db(): - conn = sqlite3.connect(DB_PATH) - conn.execute(''' - CREATE TABLE IF NOT EXISTS nodes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - ip TEXT UNIQUE, - user TEXT, - status TEXT - ) - ''') - conn.commit() - conn.close() +async def handle_telegram_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + # Den eigenen @Benutzernamen des Bots dynamisch abfragen + bot_username = f"@{context.bot.username}" + + chat_type = update.effective_chat.type + user_msg = update.message.text + user_id = str(update.message.from_user.id) -init_db() + # 1. Gruppen-Logik: Nur reagieren, wenn der Bot @erwähnt wird + if chat_type in ['group', 'supergroup']: + if bot_username not in user_msg: + return # Bot wurde nicht erwähnt, Nachricht ignorieren + + # Den @Namen aus dem Text entfernen, damit die KI nicht verwirrt wird + user_msg = user_msg.replace(bot_username, "").strip() + else: + # Im Einzelchat kann optional weiterhin nur der Admin zugelassen werden. + # Wenn du willst, dass auch andere den Bot privat nutzen können, entferne diesen Block: + if user_id != ALLOWED_ID: + await update.message.reply_text("Zugriff auf den privaten Chat verweigert. 🔒") + return -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn + # Tipp-Status anzeigen + await update.message.reply_chat_action(action="typing") -# --- WebSocket Manager für Logs & Chat --- + # 2. KI fragen + ai_response = await get_ai_response(user_msg, get_system_prompt()) + + commands = re.findall(r'(.*?)', ai_response, re.I | re.S) + clean_msg = re.sub(r'.*?', '', ai_response, flags=re.I | re.S).strip() + + # KI Text-Antwort senden + if clean_msg: + await update.message.reply_text(clean_msg) + + # 3. Befehle ausführen (mit strengem Sicherheits-Check!) + if commands: + if user_id != ALLOWED_ID: + await update.message.reply_text("⚠️ **Sicherheits-Sperre:** Die KI wollte einen Server-Befehl ausführen, aber du hast keine Administrator-Rechte dafür.") + return + + for target, cmd in commands: + await update.message.reply_text(f"⏳ Führe aus auf *{target}*:\n`{cmd}`", parse_mode='Markdown') + + # Node in DB suchen + conn = get_db() + n = conn.execute('SELECT * FROM nodes WHERE ip=? OR name=?', (target.strip(), target.strip())).fetchone() + conn.close() + + if n: + try: + proc = await asyncio.create_subprocess_shell( + f"ssh -o StrictHostKeyChecking=no {n['user']}@{n['ip']} '{cmd}'", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT + ) + stdout, _ = await proc.communicate() + output = stdout.decode('utf-8', errors='ignore').strip() + + result_text = output[:4000] if output else "✅ Befehl ohne Output ausgeführt." + await update.message.reply_text(f"💻 **Output von {n['name']}:**\n```\n{result_text}\n```", parse_mode='Markdown') + + chat_history.append({"role": "user", "content": f"[SYSTEM] Befehl '{cmd}' auf {target} fertig:\n{result_text}"}) + except Exception as e: + await update.message.reply_text(f"❌ Fehler bei der Ausführung: {e}") + else: + await update.message.reply_text(f"⚠️ Node '{target}' nicht in der Datenbank gefunden.") + +# --- FASTAPI LIFESPAN EVENTS (Bot starten/stoppen) --- +@app.on_event("startup") +async def startup_event(): + global telegram_app + # Prüfe auch, ob der Token nicht aus Versehen noch der Platzhalter ist + if TELEGRAM_TOKEN and ALLOWED_ID and "dein-telegram-bot-token" not in TELEGRAM_TOKEN: + print("🤖 Starte Telegram Bot im Hintergrund...") + try: + telegram_app = ApplicationBuilder().token(TELEGRAM_TOKEN).build() + + # Leitet alle Text-Nachrichten an unsere Funktion weiter + telegram_app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_telegram_message)) + + # Bot asynchron in die FastAPI Event-Loop einhängen + await telegram_app.initialize() + await telegram_app.start() + await telegram_app.updater.start_polling() + print("✅ Telegram Bot lauscht!") + + except InvalidToken: + print("❌ Telegram-Fehler: Der Token in der .env ist ungültig! Der Bot bleibt inaktiv, aber der Server läuft weiter.") + except Exception as e: + print(f"❌ Unerwarteter Fehler beim Telegram-Start: {e}") + else: + print("ℹ️ Telegram Bot inaktiv (Token oder ID fehlen/sind Platzhalter in der .env).") + +@app.on_event("shutdown") +async def shutdown_event(): + global telegram_app + if telegram_app: + print("🛑 Stoppe Telegram Bot...") + await telegram_app.updater.stop() + await telegram_app.stop() + await telegram_app.shutdown() + + +# --- WebSocket Manager --- class ConnectionManager: - def __init__(self): - self.active_connections: list[WebSocket] = [] - async def connect(self, websocket: WebSocket): - await websocket.accept() - self.active_connections.append(websocket) - def disconnect(self, websocket: WebSocket): - if websocket in self.active_connections: - self.active_connections.remove(websocket) - async def broadcast(self, message: str): - for connection in self.active_connections: - try: - await connection.send_text(message) - except: - pass - + def __init__(self): self.active_connections = [] + async def connect(self, ws: WebSocket): await ws.accept(); self.active_connections.append(ws) + def disconnect(self, ws: WebSocket): self.active_connections.remove(ws) + async def broadcast(self, msg: str): + for c in self.active_connections: + try: await c.send_text(msg) + except: pass manager = ConnectionManager() -# --- SSH Handshake (Nur Key kopieren) --- -async def bootstrap_ssh_only(ip, user, password): - await manager.broadcast(f"🔑 Initialisiere SSH-Handshake für {ip}...") - # Nutzt sshpass um den Key einmalig mit Passwort zu hinterlegen - ssh_copy_cmd = f"sshpass -p '{password}' ssh-copy-id -o StrictHostKeyChecking=no -i {SSH_KEY}.pub {user}@{ip}" +async def get_remote_info(ip, user): + """Versucht Linux/Mac-Infos zu lesen, falls fehlgeschlagen, dann Windows.""" + # 1. Versuch: Linux/Mac + linux_cmd = "uname -m && (sw_vers -productName 2>/dev/null || grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d= -f2 || uname -s) && (command -v docker >/dev/null 2>&1 && echo 1 || echo 0)" + ssh_cmd = f"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 {user}@{ip} \"{linux_cmd}\"" - process = subprocess.Popen(ssh_copy_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - for line in process.stdout: - await manager.broadcast(f"SSH: {line.strip()}") - - conn = get_db() - conn.execute('UPDATE nodes SET status = "Bereit (Kein Docker)" WHERE ip = ?', (ip,)) - conn.commit() - conn.close() - await manager.broadcast(f"✅ Node {ip} ist verbunden. KI kann nun Befehle senden.") + try: + output = subprocess.check_output(ssh_cmd, shell=True, stderr=subprocess.DEVNULL).decode().strip().split('\n') + if len(output) >= 2: + return { + "arch": output[0], + "os": output[1].replace('"', ''), + "docker": int(output[2]) if len(output) > 2 else 0 + } + except: + pass # Linux-Versuch gescheitert, weiter zu Windows -# --- Routen --- + # 2. Versuch: Windows (CMD) + # ver = OS Version, echo %PROCESSOR_ARCHITECTURE% = Arch, where docker = Docker Check + win_cmd = 'ver && echo %PROCESSOR_ARCHITECTURE% && (where docker >nul 2>&1 && echo 1 || echo 0)' + ssh_cmd = f"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 {user}@{ip} \"{win_cmd}\"" + + try: + output = subprocess.check_output(ssh_cmd, shell=True).decode().strip().split('\n') + # Windows Output sieht oft so aus: ["Microsoft Windows [Version 10.0...]", "AMD64", "1"] + raw_os = output[0] if len(output) > 0 else "Windows" + os_name = "Windows" + if "Version 10" in raw_os: os_name = "Windows 10/11" + elif "Version 11" in raw_os: os_name = "Windows 11" + + arch = output[1] if len(output) > 1 else "x86" + if "AMD64" in arch: arch = "x86-64" + + docker_val = int(output[2]) if len(output) > 2 else 0 + + return {"arch": arch, "os": os_name, "docker": docker_val} + except Exception as e: + print(f"Windows-Check fehlgeschlagen für {ip}: {e}") + return None + +# Nutze diese Funktion nun in bootstrap_node und refresh_status + +# --- ERWEITERTES NODE BOOTSTRAPPING (Inventur) --- +async def bootstrap_node(ip, user, password): + await manager.broadcast(f"🔑 Kopple {ip}...") + + with open(f"{SSH_KEY}.pub", "r") as f: + pub_key = f.read().strip() + + # Wir nutzen ein absolut minimalistisches Kommando. + # Es erstellt das Verzeichnis (falls nötig) und hängt den Key an. + # Das funktioniert in der Windows CMD und der Linux Bash. + cmd_universal = f'mkdir .ssh & echo {pub_key} >> .ssh/authorized_keys' + + # sshpass direkt mit dem simplen Befehl + setup_cmd = f"sshpass -p '{password}' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null {user}@{ip} \"{cmd_universal}\"" + + try: + # Wir führen es aus. Das "2x Passwort"-Problem kommt oft von TTY-Anfragen. + # Wir unterdrücken das mit -o StrictHostKeyChecking=no + proc = subprocess.run(setup_cmd, shell=True, capture_output=True, text=True, timeout=15) + + if proc.returncode == 0: + await manager.broadcast(f"✅ Key an {ip} übertragen.") + else: + # Falls 'mkdir' einen Fehler wirft (weil Ordner existiert), ist das egal, + # solange der Key danach drin ist. + await manager.broadcast(f"ℹ️ Info: {ip} antwortet (Key-Check folgt).") + + except Exception as e: + await manager.broadcast(f"❌ Fehler: {e}") + + # Inventur (get_remote_info) prüft jetzt, ob es wirklich klappt + await manager.broadcast(f"🔍 Teste schlüssellosen Zugriff auf {ip}...") + info = await get_remote_info(ip, user) + + if info: + status = "Docker Aktiv" if info['docker'] else "Bereit (Kein Docker)" + conn = get_db() + conn.execute(''' + UPDATE nodes SET os = ?, arch = ?, docker_installed = ?, status = ? + WHERE ip = ? + ''', (info['os'], info['arch'], info['docker'], status, ip)) + conn.commit() + conn.close() + await manager.broadcast(f"✅ Node {ip} erkannt als {info['os']} ({info['arch']}).") + else: + await manager.broadcast(f"⚠️ Inventur auf {ip} fehlgeschlagen.") +# --- ROUTES --- @app.get("/") async def index(request: Request): @@ -200,17 +383,72 @@ async def index(request: Request): conn.close() return templates.TemplateResponse("index.html", {"request": request, "nodes": nodes}) +@app.get("/api/node/{node_id}") +async def get_node(node_id: int): + conn = get_db() + node = conn.execute('SELECT * FROM nodes WHERE id = ?', (node_id,)).fetchone() + conn.close() + return dict(node) if node else {} + +@app.put("/api/node/{node_id}") +async def api_update_node(node_id: int, request: Request): + data = await request.json() + + conn = get_db() + try: + conn.execute(''' + UPDATE nodes SET + name = ?, + ip = ?, + user = ?, + sudo_password = ?, + os = ?, + arch = ?, + status = ?, + docker_installed = ? + WHERE id = ? + ''', ( + data.get("name"), + data.get("ip"), + data.get("user"), + data.get("sudo_password"), + data.get("os"), + data.get("arch"), + data.get("status"), + data.get("docker_installed"), + node_id + )) + conn.commit() + return {"status": "success"} + except Exception as e: + print(f"Update Fehler: {e}") + return {"status": "error", "message": str(e)}, 500 + finally: + conn.close() + @app.post("/add_node") async def add_node(background_tasks: BackgroundTasks, name: str = Form(...), ip: str = Form(...), user: str = Form(...), password: str = Form(...)): conn = get_db() try: - conn.execute('INSERT INTO nodes (name, ip, user, status) VALUES (?, ?, ?, ?)', (name, ip, user, "Kopplung...")) + # Speichere Initialdaten inkl. Sudo-Passwort + conn.execute(''' + INSERT INTO nodes (name, ip, user, sudo_password, status) + VALUES (?, ?, ?, ?, ?) + ''', (name, ip, user, password, "Kopplung...")) conn.commit() - background_tasks.add_task(bootstrap_ssh_only, ip, user, password) + background_tasks.add_task(bootstrap_node, ip, user, password) except sqlite3.IntegrityError: pass finally: conn.close() return RedirectResponse(url="/", status_code=303) +@app.post("/edit_node/{node_id}") +async def edit_node(node_id: int, name: str = Form(...), ip: str = Form(...), user: str = Form(...)): + conn = get_db() + conn.execute('UPDATE nodes SET name=?, ip=?, user=? WHERE id=?', (name, ip, user, node_id)) + conn.commit() + conn.close() + return RedirectResponse(url="/", status_code=303) + @app.post("/remove_node/{node_id}") async def remove_node(node_id: int): conn = get_db() @@ -219,12 +457,142 @@ async def remove_node(node_id: int): conn.close() return RedirectResponse(url="/", status_code=303) +@app.get("/refresh_status/{node_id}") +async def refresh_status(node_id: int): + conn = get_db() + node = conn.execute('SELECT * FROM nodes WHERE id = ?', (node_id,)).fetchone() + if not node: return {"status": "Offline"} + + info = await get_remote_info(node['ip'], node['user']) + + if info: + new_status = "Docker Aktiv" if info['docker'] else "Bereit (Kein Docker)" + conn.execute('UPDATE nodes SET status=?, os=?, arch=?, docker_installed=? WHERE id=?', + (new_status, info['os'], info['arch'], info['docker'], node_id)) + conn.commit() + result = {"status": new_status, "os": info['os'], "arch": info['arch'], "docker": info['docker']} + else: + new_status = "Offline" + conn.execute('UPDATE nodes SET status=? WHERE id=?', (new_status, node_id)) + conn.commit() + result = {"status": new_status, "os": node['os'], "arch": node['arch'], "docker": node['docker_installed']} + + conn.close() + return result + +# --- WebSockets Terminal / Chat / Logs (Integration wie gehabt) --- +@app.websocket("/ws/install_logs") +async def log_websocket(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: await websocket.receive_text() + except WebSocketDisconnect: manager.disconnect(websocket) + +@app.websocket("/ws/terminal/{ip}") +async def terminal_websocket(websocket: WebSocket, ip: str): + await websocket.accept() + conn = get_db() + node = conn.execute('SELECT * FROM nodes WHERE ip = ?', (ip,)).fetchone() + conn.close() + + if not node: + await websocket.close() + return + + master_fd, slave_fd = pty.openpty() + # Wir starten SSH im interaktiven Modus + proc = await asyncio.create_subprocess_exec( + "ssh", + "-i", SSH_KEY, # <--- Das hier ist entscheidend! + "-o", "StrictHostKeyChecking=no", + "-o", "BatchMode=yes", # Verhindert, dass SSH hängen bleibt, falls der Key doch nicht geht + "-t", f"{node['user']}@{ip}", + stdin=slave_fd, stdout=slave_fd, stderr=slave_fd + ) + + async def pty_to_ws(): + fl = fcntl.fcntl(master_fd, fcntl.F_GETFL) + fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + try: + while True: + await asyncio.sleep(0.01) + try: + data = os.read(master_fd, 1024).decode(errors='ignore') + if data: + await websocket.send_text(data) + except BlockingIOError: + continue + except: + pass + + async def ws_to_pty(): + try: + while True: + data = await websocket.receive_text() + # Prüfen, ob es ein Resize-Kommando (JSON) ist + if data.startswith('{"type":"resize"'): + resize_data = json.loads(data) + cols = resize_data['cols'] + rows = resize_data['rows'] + # Das hier setzt die Größe des PTY im Betriebssystem + s = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, s) + else: + # Normale Terminal-Eingabe + os.write(master_fd, data.encode()) + except: + pass + + try: + await asyncio.gather(pty_to_ws(), ws_to_pty()) + finally: + if proc.returncode is None: + proc.terminate() + os.close(master_fd) + os.close(slave_fd) + +@app.websocket("/ws/chat") +async def chat_endpoint(websocket: WebSocket): + await websocket.accept() + try: + while True: + user_msg = await websocket.receive_text() + ai_response = await get_ai_response(user_msg, get_system_prompt()) + commands = re.findall(r'(.*?)', ai_response, re.I | re.S) + clean_msg = re.sub(r'.*?', '', ai_response, flags=re.I | re.S).strip() + if clean_msg: await websocket.send_text(clean_msg) + if commands: + tasks = [] + for target, cmd in commands: + conn = get_db(); n = conn.execute('SELECT * FROM nodes WHERE ip=? OR name=?', (target.strip(), target.strip())).fetchone(); conn.close() + if n: tasks.append(run_remote_task(n['ip'], n['user'], cmd.strip())) + if tasks: + await websocket.send_text("ℹ️ *Führe Befehle aus...*") + await asyncio.gather(*tasks) + summary = await get_ai_response("Zusammenfassung der Ergebnisse?", get_system_prompt()) + await websocket.send_text(f"--- Info ---\n{summary}") + except: pass + +async def run_remote_task(ip, user, cmd): + await manager.broadcast(f"🚀 Task: {cmd} auf {ip}") + proc = await asyncio.create_subprocess_shell(f"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null {user}@{ip} '{cmd}'", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT) + full_output = "" + while True: + line = await proc.stdout.readline() + if not line: break + out = line.decode('utf-8', errors='ignore').strip() + if out: await manager.broadcast(f"🛠️ {out}"); full_output += out + "\n" + await proc.wait() + chat_history.append({"role": "user", "content": f"[SYSTEM] Befehl '{cmd}' auf {ip} fertig:\n{full_output or 'Kein Output'}"}) + +# --- Settings API --- @app.get("/api/settings") async def get_settings(): return { "provider": AI_PROVIDER, "google_model": GOOGLE_MODEL, "openai_model": OPENAI_MODEL, + "nvidia_model": NVIDIA_MODEL, "ollama_model": OLLAMA_MODEL, "ollama_base_url": OLLAMA_BASE_URL # URL ans Frontend schicken } @@ -232,7 +600,7 @@ async def get_settings(): @app.post("/api/settings") async def update_settings(request: Request): # WICHTIG: OLLAMA_BASE_URL als global deklarieren - global AI_PROVIDER, GOOGLE_MODEL, OPENAI_MODEL, OLLAMA_MODEL, OLLAMA_BASE_URL + global AI_PROVIDER, GOOGLE_MODEL, OPENAI_MODEL, NVIDIA_MODEL, OLLAMA_MODEL, OLLAMA_BASE_URL data = await request.json() provider = data.get("provider") @@ -249,6 +617,9 @@ async def update_settings(request: Request): elif provider == "openai" and model: OPENAI_MODEL = model set_key(ENV_FILE, "OPENAI_MODEL", model) + elif provider == "nvidia" and model: + NVIDIA_MODEL = model + set_key(ENV_FILE, "NVIDIA_MODEL", model) elif provider == "ollama" and model: OLLAMA_MODEL = model set_key(ENV_FILE, "OLLAMA_MODEL", model) @@ -276,13 +647,30 @@ async def get_models(provider: str, url: str = None): elif provider == "openai": if not OPENAI_API_KEY or "hier" in OPENAI_API_KEY: return {"models": []} # Hier greift das Backend auf deinen OpenAI API-Key zu - import openai client = openai.AsyncOpenAI(api_key=OPENAI_API_KEY) response = await client.models.list() # Nur GPT-Modelle filtern, um die Liste sauber zu halten models = [m.id for m in response.data if "gpt" in m.id or "o1" in m.id] models.sort() - + + elif provider == "nvidia": + if not NVIDIA_API_KEY or "hier" in NVIDIA_API_KEY: + return {"models": ["FEHLER: Key fehlt in .env"]} + + client = openai.AsyncOpenAI( + api_key=NVIDIA_API_KEY, + base_url="https://integrate.api.nvidia.com/v1" + ) + try: + # Timeout hinzufügen, damit die Seite nicht ewig hängt + response = await asyncio.wait_for(client.models.list(), timeout=5.0) + models = [m.id for m in response.data] + models.sort() + return {"models": models} + except Exception as e: + print(f"NVIDIA API Error: {e}") + return {"models": [f"NVIDIA Fehler: {str(e)[:20]}..."]} + elif provider == "google": if not GOOGLE_API_KEY: return {"models": ["API-Key fehlt"]} @@ -305,189 +693,16 @@ async def get_models(provider: str, url: str = None): print(f"Fehler beim Abrufen der Modelle für {provider}: {str(e)}") return {"models": []} # Gibt eine leere Liste zurück -> Frontend nutzt Fallback -# --- WebSockets --- +@app.get("/debug_keys") +async def debug_keys(): + return { + "AI_PROVIDER": AI_PROVIDER, + "NVIDIA_KEY_LOADED": bool(NVIDIA_API_KEY and "hier" not in NVIDIA_API_KEY), + "NVIDIA_KEY_START": NVIDIA_API_KEY[:10] if NVIDIA_API_KEY else "Missing", + "GOOGLE_KEY_LOADED": bool(GOOGLE_API_KEY), + "OLLAMA_URL": OLLAMA_BASE_URL + } -@app.websocket("/ws/install_logs") -async def log_websocket(websocket: WebSocket): - await manager.connect(websocket) - try: - while True: - await websocket.receive_text() - except WebSocketDisconnect: - manager.disconnect(websocket) - -@app.websocket("/ws/terminal/{ip}") -async def terminal_websocket(websocket: WebSocket, ip: str): - await websocket.accept() - conn = get_db() - node = conn.execute('SELECT * FROM nodes WHERE ip = ?', (ip,)).fetchone() - conn.close() - - if not node: - await websocket.send_text("\r\nFehler: Node nicht gefunden.\r\n") - await websocket.close() - return - - # Pseudo-Terminal für interaktive SSH-Session - master_fd, slave_fd = pty.openpty() - proc = await asyncio.create_subprocess_exec( - "ssh", "-o", "StrictHostKeyChecking=no", "-t", f"{node['user']}@{ip}", - stdin=slave_fd, stdout=slave_fd, stderr=slave_fd - ) - - async def pty_to_ws(): - # Setzt den Master-FD auf non-blocking - fl = fcntl.fcntl(master_fd, fcntl.F_GETFL) - fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - try: - while True: - await asyncio.sleep(0.01) - try: - data = os.read(master_fd, 1024).decode(errors='ignore') - if data: - await websocket.send_text(data) - except BlockingIOError: - continue - except Exception: pass - - async def ws_to_pty(): - try: - while True: - data = await websocket.receive_text() - os.write(master_fd, data.encode()) - except Exception: pass - - try: - await asyncio.gather(pty_to_ws(), ws_to_pty()) - finally: - if proc.returncode is None: proc.terminate() - os.close(master_fd) - os.close(slave_fd) - -# --- WEBSOCKET CHAT UPDATE --- - -@app.websocket("/ws/chat") -async def chat_endpoint(websocket: WebSocket): - await websocket.accept() - - # Check ob Key vorhanden ist - if AI_PROVIDER == "google" and not GOOGLE_API_KEY: - await websocket.send_text("⚠️ **Konfigurationsfehler:** Kein GOOGLE_API_KEY in der `.env` gefunden!") - elif AI_PROVIDER == "openai" and not OPENAI_API_KEY: - await websocket.send_text("⚠️ **Konfigurationsfehler:** Kein OPENAI_API_KEY in der `.env` gefunden!") - - try: - while True: - user_msg = await websocket.receive_text() - sys_prompt = get_system_prompt() - ai_response = await get_ai_response(user_msg, sys_prompt) - - # Befehle extrahieren - commands_to_run = re.findall(r'(.*?)', ai_response, re.IGNORECASE | re.DOTALL) - clean_chat_msg = re.sub(r'.*?', '', ai_response, flags=re.IGNORECASE | re.DOTALL).strip() - - if clean_chat_msg: - await websocket.send_text(clean_chat_msg) - - if commands_to_run: - # Liste für alle laufenden Tasks erstellen - tasks = [] - for target_ip, cmd in commands_to_run: - conn = get_db() - node = conn.execute('SELECT * FROM nodes WHERE ip = ? OR name = ?', (target_ip.strip(), target_ip.strip())).fetchone() - conn.close() - - if node: - # Wir erstellen den Task, starten ihn aber noch nicht separat - tasks.append(run_remote_task(node['ip'], node['user'], cmd.strip(), "KI-Kommando")) - - if tasks: - # Dem Nutzer im Chat kurz Bescheid geben - await websocket.send_text("ℹ️ *Warte auf Rückmeldungen der Nodes...*") - - # Jetzt werden alle SSH-Befehle gleichzeitig gestartet und abgewartet - await asyncio.gather(*tasks) - - # Sobald asyncio.gather fertig ist, geht es hier weiter mit dem Follow-up: - follow_up_prompt = "Die Befehle wurden ausgeführt. Bitte fasse die Ergebnisse kurz zusammen." - ai_summary = await get_ai_response(follow_up_prompt, sys_prompt) - - await websocket.send_text("--- Ergebnis-Zusammenfassung ---") - await websocket.send_text(ai_summary) - - except Exception as e: - print(f"Chat Fehler: {e}") - -async def run_remote_task(ip, user, cmd, task_name): - global chat_history # Zugriff auf das Gedächtnis der KI - - await manager.broadcast(f"🚀 KI-Task gestartet: {cmd} auf {ip}") - - ssh_cmd = f"ssh -o StrictHostKeyChecking=no {user}@{ip} '{cmd}'" - - process = await asyncio.create_subprocess_shell( - ssh_cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT - ) - - # Hier speichern wir die komplette Terminal-Ausgabe - full_output = "" - - while True: - line = await process.stdout.readline() - if not line: - break - decoded_line = line.decode('utf-8', errors='ignore').strip() - if decoded_line: - await manager.broadcast(f"🛠️ {decoded_line}") - full_output += decoded_line + "\n" - # Gib dem Event-Loop kurz Zeit, andere Tasks (wie WebSockets) zu bedienen - await asyncio.sleep(0.001) - - await process.wait() - - # --- NEU: Feedback an die KI --- - # Wir bereiten den Bericht vor - if not full_output.strip(): - full_output = "Befehl wurde ohne Ausgabe ausgeführt (Exit Code 0)." - - system_report = f"[SYSTEM-RÜCKMELDUNG] Der Befehl '{cmd}' auf Node {ip} wurde beendet. Ausgabe des Terminals:\n{full_output}" - - # Wir schmuggeln den Bericht als "User"-Nachricht in den Verlauf, - # damit die KI beim nächsten Mal weiß, was passiert ist. - chat_history.append({"role": "user", "content": system_report}) - # ------------------------------- - - if "docker" in cmd.lower() and "install" in cmd.lower(): - # Kleiner Bonus: Nur updaten, wenn wirklich installiert wird - await manager.broadcast(f"✨ Bitte aktualisiere den Status für {ip} manuell über das Refresh-Icon.") - - await manager.broadcast(f"✅ Befehl auf {ip} abgeschlossen.") - -# --- Neuer Endpunkt: Manueller Refresh-Check --- -@app.get("/refresh_status/{node_id}") -async def refresh_status(node_id: int): - conn = get_db() - node = conn.execute('SELECT * FROM nodes WHERE id = ?', (node_id,)).fetchone() - - if node: - # Kurzer Check via SSH, ob Docker antwortet - check_cmd = "command -v docker >/dev/null 2>&1 && echo 'Docker Aktiv' || echo 'Bereit (Kein Docker)'" - ssh_cmd = f"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=2 {node['user']}@{node['ip']} \"{check_cmd}\"" - try: - # Wir führen den Befehl aus - new_status = subprocess.check_output(ssh_cmd, shell=True).decode().strip() - except Exception: - new_status = "Offline/Fehler" - - conn.execute('UPDATE nodes SET status = ? WHERE id = ?', (new_status, node_id)) - conn.commit() - conn.close() - return {"status": new_status} # Wir senden nur den Status zurück - - conn.close() - return {"status": "Unbekannt"} if __name__ == "__main__": import uvicorn diff --git a/requirements.txt b/requirements.txt index 0c8a556..3f41f1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ sqlalchemy websockets openai google-genai -python-dotenv \ No newline at end of file +python-dotenv +python-telegram-bot \ No newline at end of file diff --git a/setup.sh b/setup.sh index b6f561a..8bff0b4 100644 --- a/setup.sh +++ b/setup.sh @@ -2,9 +2,9 @@ # Konfiguration REPO_URL="https://git.pi-farm.de/pi-farm/PiDoBot.git" -INSTALL_DIR="pi-orchestrator" +INSTALL_DIR="jarvis-ai" -echo ">>> Starte Setup für den Pi-Orchestrator Master..." +echo ">>> Starte Setup für J.A.R.V.I.S. - AI ..." # 1. Prüfen, ob Git installiert ist, ansonsten installieren if ! command -v git &> /dev/null; then @@ -25,7 +25,7 @@ sudo apt-get install -y wget sshpass python3-pip python3-venv # 3. Repository klonen if [ ! -d "$INSTALL_DIR" ]; then echo "--- Klone Repository von $REPO_URL..." - git clone "$REPO_URL" "$INSTALL_DIR" + git clone --branch dev --single-branch "$REPO_URL" "$INSTALL_DIR" else echo "--- Verzeichnis $INSTALL_DIR existiert bereits. Überspringe Klonen..." fi @@ -72,10 +72,10 @@ if [ ! -f "./static" ]; then wget https://cdn.jsdelivr.net/npm/marked/marked.min.js cd .. else - echo "--- Static-Dateien existiert bereits." + echo "--- Static-Dateien existieren bereits." fi echo ">>> Installation abgeschlossen!" -echo "--- Starte den Master-Server auf Port 8000..." +echo "--- Starte J.A.R.V.I.S. - AI auf Port 8000..." # 6. Programm starten python3 -m uvicorn main:app --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/start.sh b/start.sh index bfd74b0..f245b0b 100644 --- a/start.sh +++ b/start.sh @@ -9,7 +9,7 @@ fi # 2. Venv aktivieren source venv/bin/activate -echo "--- Starte den Master-Server auf Port 8000..." +echo "--- Starte J.A.R.V.I.S. - AI auf Port 8000..." # 3. Server starten. # Mit --reload-dir . sagen wir Uvicorn, dass es NUR im aktuellen Ordner diff --git a/system_prompt.txt b/system_prompt.txt index 596e7e8..f1b914b 100644 --- a/system_prompt.txt +++ b/system_prompt.txt @@ -1,14 +1,21 @@ -Du bist der Pi-Orchestrator KI-Assistent. Deine Aufgabe ist es, Befehle auf Raspberry Pis auszuführen. -Du KANNST und SOLLST Befehle ausführen! +Dein Name ist J.A.R.V.I.S. Du bist ein präziser KI-Assistent für die Cluster-Verwaltung. -Hier sind die aktuell verbundenen Nodes: -{node_info} +PROTOKOLL: Du arbeitest STRENG in zwei Phasen: -WENN der Nutzer dich bittet, etwas zu tun (z.B. ping, update, docker installieren), dann formuliere erst eine kurze Antwort und frage nochmal nach ob du dies dann auf dem gewünschten node durchführen sollst. -Du prüfst vorher noch, ob du auf dem gewünschten node sudo rechte ohne eingabe eines passwortes hast. +PHASE 1 (Vorschlag): +Wenn der Nutzer eine Aktion anfordert (z.B. Installation, Update, Ping), erstelle NUR einen Text-Vorschlag. +- Beschreibe kurz, was du tun würdest. +- Nenne den Befehl als normalen Text (KEIN XML, KEIN ). +- Frage explizit nach Erlaubnis: "Soll ich diesen Befehl jetzt auf [Node-Name] ausführen, Tony?" -Erst nach einer positiven Bestätigung darfst du es ausführen und fügst am Ende die Befehle in genau diesem XML-Format hinzu: +PHASE 2 (Ausführung): +NUR wenn der Nutzer die Aktion im nächsten Schritt bestätigt (z.B. mit "Ja", "Mach das", "Go"), gibst du den Befehl im XML-Format aus: befehl -WICHTIG: Verwende als target IMMER die IP-Adresse des Nodes. -Bei Befehlen wie 'ping' oder 'top', die nicht enden, MUSS ein Limit gesetzt werden (z.B. ping -c 4 IP). \ No newline at end of file +REGELN: +- Beginne jede Antwort mit: "Tony..." +- Nodes: {node_info} +- Nutze für immer die IP-Adresse. +- Begrenze endlose Befehle (z.B. ping -c 4). +- Passwörter für sudo/Admin darfst du aus der Datenbank beziehen, falls nötig. +- Führe NIEMALS einen Tag in PHASE 1 aus. \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 3680a74..fed95aa 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,7 +2,7 @@ - Pi-Orchestrator AI Dashboard + J.A.R.V.I.S. AI Dashboard @@ -14,7 +14,14 @@ - +
-
🤖 KI-Orchestrator
- +
🤖 J.A.R.V.I.S. AI Dashboard
- - -
- - + +
+ - - + -
- - +
-
-
-
- +
+ +
{% for node in nodes %} -
-
-
{{ node.name }}
-
+
+
+
{{ node.name }}
+
+ +
-
- {{ node.ip }} - -
-
- - {{ node.status }} -
-
+
+ {{ node.ip }} + +
+
+ {{ node.os }} + {{ node.arch }} +
+
+ + {{ node.status }} +
+ +
{% endfor %}
- +
-
+
-
💬 KI Chat
+
💬 J.A.R.V.I.S. - Chat
- - + +
@@ -235,7 +192,7 @@
📜 System Logs
-
Warte auf Aufgaben...
+
Warte auf Daten...
@@ -253,10 +210,10 @@

Neuen Node hinzufügen

- + - +
@@ -265,12 +222,66 @@
+ +