Merge pull request 'Webseite überarbeitet und Telegram Bot funktion hinzugefügt' (#1) from dev into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
7
.env
7
.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 <EXECUTE target=\"IP\">befehl</EXECUTE> hinzu."
|
||||
TELEGRAM_BOT_TOKEN=dein-telegram-bot-token
|
||||
ALLOWED_TELEGRAM_USER_ID=deine-telegram-id
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
735
main.py
735
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 <EXECUTE target=\"IP\">cmd</EXECUTE>"
|
||||
print(f"⚠️ Warnung: {PROMPT_FILE} nicht gefunden. Nutze Fallback.")
|
||||
template = "Du bist ein Cluster-Orchestrator. Nodes:\n{node_info}\nBefehle via <EXECUTE target=\"IP\">cmd</EXECUTE>"
|
||||
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'<EXECUTE target="(.*?)">(.*?)</EXECUTE>', ai_response, re.I | re.S)
|
||||
clean_msg = re.sub(r'<EXECUTE.*?>.*?</EXECUTE>', '', 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'<EXECUTE target="(.*?)">(.*?)</EXECUTE>', ai_response, re.I | re.S)
|
||||
clean_msg = re.sub(r'<EXECUTE.*?>.*?</EXECUTE>', '', 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'<EXECUTE target="(.*?)">(.*?)</EXECUTE>', ai_response, re.IGNORECASE | re.DOTALL)
|
||||
clean_chat_msg = re.sub(r'<EXECUTE.*?>.*?</EXECUTE>', '', 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
|
||||
|
||||
@@ -8,4 +8,5 @@ sqlalchemy
|
||||
websockets
|
||||
openai
|
||||
google-genai
|
||||
python-dotenv
|
||||
python-dotenv
|
||||
python-telegram-bot
|
||||
10
setup.sh
10
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
|
||||
2
start.sh
2
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
|
||||
|
||||
@@ -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 <EXECUTE>).
|
||||
- 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:
|
||||
<EXECUTE target="IP_ADRESSE">befehl</EXECUTE>
|
||||
|
||||
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).
|
||||
REGELN:
|
||||
- Beginne jede Antwort mit: "Tony..."
|
||||
- Nodes: {node_info}
|
||||
- Nutze für <EXECUTE> 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 <EXECUTE> Tag in PHASE 1 aus.
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pi-Orchestrator AI Dashboard</title>
|
||||
<title>J.A.R.V.I.S. AI Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<link href="/static/gridstack.min.css" rel="stylesheet"/>
|
||||
@@ -14,7 +14,14 @@
|
||||
<script src="/static/marked.min.js"></script>
|
||||
|
||||
<style>
|
||||
.grid-stack { background: #0f172a; min-height: 100vh; padding: 10px; }
|
||||
body { margin: 0; background: #0f172a; }
|
||||
|
||||
.grid-stack {
|
||||
background: transparent;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.grid-stack-item-content {
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
@@ -25,11 +32,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-container, #terminal {
|
||||
flex: 1;
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: black;
|
||||
background: black;
|
||||
}
|
||||
|
||||
#install-log {
|
||||
@@ -54,11 +60,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding-top: 60px; /* Platz für die Toolbar */
|
||||
}
|
||||
|
||||
.top-toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -77,23 +78,9 @@
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.toolbar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-controls label {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.toolbar-title { font-weight: bold; font-size: 1.1em; color: #38bdf8; }
|
||||
.toolbar-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.toolbar-controls label { white-space: nowrap; font-size: 12px; color: #94a3b8; }
|
||||
|
||||
#ollama-url-container {
|
||||
display: none;
|
||||
@@ -101,133 +88,103 @@
|
||||
gap: 8px;
|
||||
border-left: 1px solid #475569;
|
||||
padding-left: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-controls select,
|
||||
.toolbar-controls input {
|
||||
height: 32px;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
#ollama-url { width: 100%; min-width: 200px; }
|
||||
.toolbar-controls select, .toolbar-controls input {
|
||||
height: 32px; background: #0f172a; color: white;
|
||||
border: 1px solid #475569; border-radius: 4px; padding: 0 8px; font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar-controls select:focus,
|
||||
.toolbar-controls input:focus {
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
|
||||
/* Schicke Toolbar-Buttons */
|
||||
.btn-tool {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 32px; padding: 0 12px; border-radius: 4px; font-size: 12px;
|
||||
font-weight: bold; display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.save-btn { background-color: #059669; color: white; }
|
||||
.add-node-btn { background-color: #2563eb; color: white; }
|
||||
|
||||
.save-btn {
|
||||
background-color: #059669;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-btn:hover { background-color: #10b981; }
|
||||
|
||||
.add-node-btn {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-node-btn:hover { background-color: #3b82f6; }
|
||||
|
||||
#settings-status {
|
||||
color: #10b981;
|
||||
font-size: 11px;
|
||||
min-width: 80px;
|
||||
.node-badge {
|
||||
background: #334155; padding: 2px 5px; border-radius: 4px;
|
||||
font-size: 8px; text-transform: uppercase; font-weight: bold; color: #94a3b8;
|
||||
}
|
||||
|
||||
.markdown-content pre { background: #000; padding: 8px; border-radius: 4px; border: 1px solid #334155; overflow-x: auto; margin: 8px 0; }
|
||||
.markdown-content code { font-family: monospace; color: #4ade80; }
|
||||
.markdown-content p { margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-white overflow-hidden">
|
||||
<body class="text-white">
|
||||
|
||||
<div class="top-toolbar">
|
||||
<div class="toolbar-title">🤖 KI-Orchestrator</div>
|
||||
|
||||
<div class="toolbar-title">🤖 J.A.R.V.I.S. AI Dashboard</div>
|
||||
<div class="toolbar-controls">
|
||||
<button onclick="addNode()" class="btn-tool add-node-btn">
|
||||
<span>+</span> Node hinzufügen
|
||||
</button>
|
||||
|
||||
<div class="h-6 w-px bg-slate-700 mx-2"></div>
|
||||
|
||||
<label for="ai-provider">Provider:</label>
|
||||
<button onclick="addNode()" class="btn-tool add-node-btn"><span>+</span> Node</button>
|
||||
<div class="h-6 w-px bg-slate-700 mx-1"></div>
|
||||
<label>Provider:</label>
|
||||
<select id="ai-provider" onchange="updateModelDropdown(false)">
|
||||
<option value="google">Google Gemini</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="nvidia">NVIDIA</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</select>
|
||||
|
||||
<label for="ai-model">Modell:</label>
|
||||
<label>Modell:</label>
|
||||
<select id="ai-model"></select>
|
||||
|
||||
<div id="ollama-url-container">
|
||||
<label for="ollama-url">URL:</label>
|
||||
<input type="text" id="ollama-url" onblur="updateModelDropdown(false)" placeholder="http://192.168.x.x:11434/v1">
|
||||
<input type="text" id="ollama-url" onblur="updateModelDropdown(false)" placeholder="URL: http://...">
|
||||
</div>
|
||||
|
||||
<button class="btn-tool save-btn" onclick="saveSettings()">Speichern</button>
|
||||
<span id="settings-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex h-screen">
|
||||
<div class="w-64 bg-slate-900 border-r border-slate-800 p-4 flex flex-col">
|
||||
<!-- <h2 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<span class="text-blue-400">📍</span> Nodes
|
||||
</h2> -->
|
||||
<div class="flex min-h-screen pt-[55px]">
|
||||
|
||||
<div class="w-64 bg-slate-900 border-r border-slate-800 p-4 flex flex-col fixed left-0 top-[55px] bottom-0 z-10">
|
||||
<div id="node-list" class="flex-1 overflow-y-auto space-y-2">
|
||||
{% for node in nodes %}
|
||||
<div class="p-3 bg-slate-800 rounded border border-slate-700 relative group" id="node-card-{{ node.id }}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="text-sm font-bold">{{ node.name }}</div>
|
||||
<form action="/remove_node/{{ node.id }}" method="post">
|
||||
<div class="p-3 bg-slate-800 rounded border border-slate-700 relative group" id="node-card-{{ node.id }}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="text-sm font-bold" id="node-name-{{ node.id }}">{{ node.name }}</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editNode({{ node.id }}, '{{ node.name }}', '{{ node.ip }}')" class="text-slate-500 hover:text-sky-400 text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
</button>
|
||||
<form action="/remove_node/{{ node.id }}" method="post" onsubmit="return confirm('Entfernen?')">
|
||||
<button type="submit" class="text-slate-500 hover:text-red-500 text-xs">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono flex justify-between items-center">
|
||||
{{ node.ip }}
|
||||
<button onclick="refreshNodeStatus({{ node.id }})" class="hover:text-blue-400">🔄</button>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span id="led-{{ node.id }}" class="h-2 w-2 rounded-full {% if node.status == 'Docker Aktiv' %} bg-blue-500 shadow-[0_0_8px_#3b82f6] {% else %} bg-yellow-500 {% endif %}"></span>
|
||||
<span id="badge-{{ node.id }}" class="text-[9px] uppercase font-mono text-slate-300">{{ node.status }}</span>
|
||||
</div>
|
||||
<button onclick="openTerminal('{{ node.ip }}')" class="mt-2 w-full text-[10px] bg-slate-700 hover:bg-slate-600 py-1 rounded transition-colors">Konsole öffnen</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono flex justify-between items-center mb-1">
|
||||
<span id="node-ip-{{ node.id }}">{{ node.ip }}</span>
|
||||
<button onclick="refreshNodeStatus({{ node.id }})" class="hover:text-blue-400">🔄</button>
|
||||
</div>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<span id="os-{{ node.id }}" class="node-badge truncate max-w-[80px]">{{ node.os }}</span>
|
||||
<span id="arch-{{ node.id }}" class="node-badge">{{ node.arch }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span id="led-{{ node.id }}" class="h-2 w-2 rounded-full {% if 'Aktiv' in node.status %} bg-blue-500 shadow-[0_0_8px_#3b82f6] {% else %} bg-yellow-500 {% endif %}"></span>
|
||||
<span id="badge-{{ node.id }}" class="text-[9px] uppercase font-mono text-slate-300">{{ node.status }}</span>
|
||||
</div>
|
||||
<button onclick="openTerminal('{{ node.ip }}')" class="mt-2 w-full text-[10px] bg-slate-700 hover:bg-slate-600 py-1 rounded">Konsole</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button onclick="localStorage.removeItem('pi-orch-layout-v2'); location.reload();" class="mt-4 text-[10px] text-slate-500 hover:text-white uppercase text-center w-full">Layout Reset</button>
|
||||
<button onclick="localStorage.removeItem('pi-orch-layout-v2'); location.reload();" class="mt-4 text-[10px] text-slate-500 hover:text-white uppercase">Layout Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="flex-1 ml-64 bg-slate-950 overflow-y-auto">
|
||||
<div class="grid-stack">
|
||||
<div class="grid-stack-item" gs-id="chat-widget" gs-w="6" gs-h="5" gs-x="0" gs-y="0">
|
||||
<div class="grid-stack-item-content">
|
||||
<div class="widget-header"><span>💬 KI Chat</span> <span>⠿</span></div>
|
||||
<div class="widget-header"><span>💬 J.A.R.V.I.S. - Chat</span> <span>⠿</span></div>
|
||||
<div id="chat-window" class="flex-1 p-4 overflow-y-auto text-sm space-y-2 bg-[#0f172a]/50"></div>
|
||||
<div class="p-2 border-t border-slate-700 flex bg-slate-800">
|
||||
<input id="user-input" type="text" class="flex-1 bg-slate-900 p-2 rounded text-xs outline-none border border-slate-700 focus:border-blue-500" placeholder="Nachricht (Enter zum Senden)..." autocomplete="off">
|
||||
<button onclick="sendMessage()" class="ml-2 bg-blue-600 hover:bg-blue-500 px-4 py-1 rounded text-xs transition-colors">Senden</button>
|
||||
<textarea id="user-input"
|
||||
class="flex-1 bg-slate-900 p-2 rounded text-xs outline-none border border-slate-700 focus:border-blue-500 resize-none overflow-y-hidden"
|
||||
placeholder="Frage eingeben... (Shift+Enter für neue Zeile)"
|
||||
rows="1"
|
||||
oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'"></textarea>
|
||||
<button onclick="sendMessage()" class="ml-2 bg-blue-600 hover:bg-blue-500 px-4 py-1 rounded text-xs">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,7 +192,7 @@
|
||||
<div class="grid-stack-item" gs-id="logs-widget" gs-w="6" gs-h="5" gs-x="6" gs-y="0">
|
||||
<div class="grid-stack-item-content">
|
||||
<div class="widget-header"><span>📜 System Logs</span> <span>⠿</span></div>
|
||||
<div id="install-log">Warte auf Aufgaben...</div>
|
||||
<div id="install-log">Warte auf Daten...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,10 +210,10 @@
|
||||
<div class="bg-slate-800 p-6 rounded-lg border border-slate-600 w-96">
|
||||
<h3 class="text-lg font-bold mb-4">Neuen Node hinzufügen</h3>
|
||||
<form action="/add_node" method="post" class="space-y-4">
|
||||
<input type="text" name="name" placeholder="Name (z.B. Pi-Worker-1)" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm" required>
|
||||
<input type="text" name="name" placeholder="Name" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm" required>
|
||||
<input type="text" name="ip" placeholder="IP Adresse" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm" required>
|
||||
<input type="text" name="user" placeholder="SSH Benutzer" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm" required>
|
||||
<input type="password" name="password" placeholder="SSH Passwort" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm" required>
|
||||
<input type="password" name="password" placeholder="Passwort" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm" required>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onclick="closeAddNode()" class="px-4 py-2 text-sm text-slate-400">Abbrechen</button>
|
||||
<button type="submit" class="bg-blue-600 px-4 py-2 text-sm rounded font-bold">Koppeln</button>
|
||||
@@ -265,12 +222,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-node-modal" class="hidden fixed inset-0 bg-black/80 flex items-center justify-center z-[2000] p-4">
|
||||
<div class="bg-slate-800 p-6 rounded-lg border border-slate-600 w-full max-w-xl">
|
||||
<h3 class="text-lg font-bold mb-4 text-sky-400">Node-Konfiguration bearbeiten</h3>
|
||||
<input type="hidden" id="edit-node-id">
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">Anzeigename</label>
|
||||
<input type="text" id="edit-node-name" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">SSH Benutzer</label>
|
||||
<input type="text" id="edit-node-user" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">Betriebssystem</label>
|
||||
<input type="text" id="edit-node-os" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">Status-Text</label>
|
||||
<input type="text" id="edit-node-status" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">IP Adresse</label>
|
||||
<input type="text" id="edit-node-ip" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">Sudo Passwort</label>
|
||||
<input type="password" id="edit-node-password" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase text-slate-400 font-bold">Architektur</label>
|
||||
<input type="text" id="edit-node-arch" class="w-full bg-slate-900 border border-slate-700 p-2 rounded text-sm text-white focus:border-sky-500 outline-none">
|
||||
</div>
|
||||
<div class="flex items-center h-full pt-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">
|
||||
<input type="checkbox" id="edit-node-docker" class="w-4 h-4 rounded border-slate-700 bg-slate-900 text-sky-600 focus:ring-sky-500">
|
||||
<span class="text-sm text-slate-300 group-hover:text-white transition-colors">Docker installiert</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-700">
|
||||
<button type="button" onclick="closeEditNode()" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">Abbrechen</button>
|
||||
<button type="button" onclick="updateNode()" class="bg-sky-600 hover:bg-sky-500 px-6 py-2 text-sm rounded font-bold transition-all shadow-lg shadow-sky-900/20">Änderungen speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentSettings = {};
|
||||
let termDataDisposable = null;
|
||||
const modelOptions = { google: [], openai: [], ollama: [] }; // Wird vom Backend befüllt
|
||||
let term, fitAddon;
|
||||
|
||||
// 1. CHAT LOGIK (Mit Enter und Fokus)
|
||||
// 1. CHAT LOGIK
|
||||
function initChat(chatWs) {
|
||||
const input = document.getElementById('user-input');
|
||||
|
||||
@@ -279,33 +290,82 @@
|
||||
if(!msg) return;
|
||||
chatWs.send(msg);
|
||||
appendChat("Du", msg, "text-slate-400 font-bold");
|
||||
|
||||
// Input zurücksetzen
|
||||
input.value = '';
|
||||
input.focus(); // Fokus zurücksetzen
|
||||
input.style.height = 'auto'; // Höhe wieder einklappen
|
||||
input.focus();
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Wenn ENTER gedrückt wird OHNE Shift -> Senden
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault(); // Verhindert den Zeilenumbruch
|
||||
sendMessage();
|
||||
}
|
||||
// Wenn ENTER + Shift gedrückt wird -> Standardverhalten (neue Zeile) bleibt
|
||||
});
|
||||
}
|
||||
|
||||
// 2. NODE MODAL LOGIK
|
||||
window.addNode = function() {
|
||||
document.getElementById('add-node-modal').classList.remove('hidden');
|
||||
// 2. MODAL LOGIK
|
||||
window.addNode = () => document.getElementById('add-node-modal').classList.remove('hidden');
|
||||
window.closeAddNode = () => document.getElementById('add-node-modal').classList.add('hidden');
|
||||
|
||||
// EDIT LOGIK
|
||||
// Ruft alle Daten ab, bevor das Modal öffnet
|
||||
window.editNode = async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/node/${id}`);
|
||||
const node = await res.json();
|
||||
|
||||
document.getElementById('edit-node-id').value = node.id;
|
||||
document.getElementById('edit-node-name').value = node.name;
|
||||
document.getElementById('edit-node-ip').value = node.ip;
|
||||
document.getElementById('edit-node-user').value = node.user;
|
||||
document.getElementById('edit-node-password').value = node.sudo_password;
|
||||
document.getElementById('edit-node-os').value = node.os;
|
||||
document.getElementById('edit-node-arch').value = node.arch;
|
||||
document.getElementById('edit-node-status').value = node.status;
|
||||
document.getElementById('edit-node-docker').checked = node.docker_installed === 1;
|
||||
|
||||
document.getElementById('edit-node-modal').classList.remove('hidden');
|
||||
} catch (e) { alert("Fehler beim Laden der Node-Daten"); }
|
||||
};
|
||||
window.closeAddNode = function() {
|
||||
document.getElementById('add-node-modal').classList.add('hidden');
|
||||
window.closeEditNode = () => document.getElementById('edit-node-modal').classList.add('hidden');
|
||||
|
||||
window.updateNode = async function() {
|
||||
const id = document.getElementById('edit-node-id').value;
|
||||
const payload = {
|
||||
name: document.getElementById('edit-node-name').value,
|
||||
ip: document.getElementById('edit-node-ip').value,
|
||||
user: document.getElementById('edit-node-user').value,
|
||||
sudo_password: document.getElementById('edit-node-password').value,
|
||||
os: document.getElementById('edit-node-os').value,
|
||||
arch: document.getElementById('edit-node-arch').value,
|
||||
status: document.getElementById('edit-node-status').value,
|
||||
docker_installed: document.getElementById('edit-node-docker').checked ? 1 : 0
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/node/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if(res.ok) {
|
||||
location.reload(); // Einfachste Methode, um alle Badges zu aktualisieren
|
||||
}
|
||||
} catch (e) { alert("Fehler beim Speichern"); }
|
||||
};
|
||||
|
||||
// 3. Node Status aktualisieren ---
|
||||
// 3. ERWEITERTER REFRESH
|
||||
window.refreshNodeStatus = async function(nodeId) {
|
||||
const badge = document.getElementById(`badge-${nodeId}`);
|
||||
const led = document.getElementById(`led-${nodeId}`);
|
||||
const osEl = document.getElementById(`os-${nodeId}`);
|
||||
const archEl = document.getElementById(`arch-${nodeId}`);
|
||||
const card = document.getElementById(`node-card-${nodeId}`);
|
||||
|
||||
// Optisches Feedback: Karte leicht ausgrauen und Text ändern
|
||||
badge.textContent = "PRÜFE...";
|
||||
card.style.opacity = "0.7";
|
||||
|
||||
@@ -313,13 +373,13 @@
|
||||
const response = await fetch(`/refresh_status/${nodeId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Status Text aktualisieren
|
||||
badge.textContent = data.status;
|
||||
osEl.textContent = data.os;
|
||||
archEl.textContent = data.arch;
|
||||
|
||||
// LED Farbe dynamisch anpassen
|
||||
if (data.status === "Docker Aktiv") {
|
||||
if (data.status.includes("Aktiv")) {
|
||||
led.className = "h-2 w-2 rounded-full bg-blue-500 shadow-[0_0_8px_#3b82f6]";
|
||||
} else if (data.status === "Offline/Fehler") {
|
||||
} else if (data.status === "Offline") {
|
||||
led.className = "h-2 w-2 rounded-full bg-red-500 shadow-[0_0_8px_#ef4444]";
|
||||
} else {
|
||||
led.className = "h-2 w-2 rounded-full bg-yellow-500";
|
||||
@@ -340,7 +400,7 @@
|
||||
document.getElementById('ai-provider').value = currentSettings.provider;
|
||||
document.getElementById('ollama-url').value = currentSettings.ollama_base_url || "http://127.0.0.1:11434/v1";
|
||||
updateModelDropdown(true);
|
||||
} catch (e) { console.error("Load Settings Error", e); }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function updateModelDropdown(isInitialLoad = false) {
|
||||
@@ -353,7 +413,12 @@
|
||||
modelSelect.innerHTML = '<option>Lade...</option>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/models?provider=${provider}&url=${encodeURIComponent(ollamaUrl)}`);
|
||||
let queryUrl = `/api/models?provider=${provider}`;
|
||||
if (provider === "ollama") {
|
||||
queryUrl += `&url=${encodeURIComponent(ollamaUrl)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(queryUrl);
|
||||
const data = await res.json();
|
||||
modelSelect.innerHTML = '';
|
||||
|
||||
@@ -364,10 +429,14 @@
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const savedModel = currentSettings[`${provider}_model`];
|
||||
if (isInitialLoad && savedModel) modelSelect.value = savedModel;
|
||||
if (isInitialLoad && currentSettings) {
|
||||
const savedModel = currentSettings[`${provider}_model`];
|
||||
if (savedModel) modelSelect.value = savedModel;
|
||||
}
|
||||
}
|
||||
} catch (e) { modelSelect.innerHTML = '<option>Fehler beim Laden</option>'; }
|
||||
} catch (e) {
|
||||
modelSelect.innerHTML = '<option>Fehler beim Laden</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
@@ -375,7 +444,6 @@
|
||||
const model = document.getElementById('ai-model').value;
|
||||
const ollama_base_url = document.getElementById('ollama-url').value;
|
||||
const statusEl = document.getElementById('settings-status');
|
||||
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
@@ -389,11 +457,12 @@
|
||||
|
||||
// 5. INITIALISIERUNG
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// GridStack mit allen Resize-Handles
|
||||
var grid = GridStack.init({
|
||||
cellHeight: 80, margin: 10, float: true,
|
||||
cellHeight: 80,
|
||||
margin: 10,
|
||||
float: true,
|
||||
handle: '.widget-header',
|
||||
resizable: { handles: 'all' } // Erlaubt Resize an allen Seiten
|
||||
resizable: { handles: 'all' }
|
||||
});
|
||||
|
||||
const savedLayout = localStorage.getItem('pi-orch-layout-v2');
|
||||
@@ -402,11 +471,29 @@
|
||||
localStorage.setItem('pi-orch-layout-v2', JSON.stringify(grid.save(false)));
|
||||
});
|
||||
|
||||
// Terminal & WebSockets
|
||||
const term = new Terminal({ theme: { background: '#000' }, fontSize: 13, convertEol: true });
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
// Terminal Setup
|
||||
term = new Terminal({ theme: { background: '#000' }, fontSize: 13, convertEol: true });
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
// WICHTIG: Wenn das Widget in GridStack vergrößert wird
|
||||
grid.on('resizestop', function(event, el) {
|
||||
if (el.getAttribute('gs-id') === 'term-widget') {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
// Sende neue Größe an das Backend
|
||||
if (window.termWs && window.termWs.readyState === WebSocket.OPEN) {
|
||||
window.termWs.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: term.cols,
|
||||
rows: term.rows
|
||||
}));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
const logWs = new WebSocket(`ws://${location.host}/ws/install_logs`);
|
||||
logWs.onmessage = (ev) => {
|
||||
@@ -417,12 +504,12 @@
|
||||
};
|
||||
|
||||
const chatWs = new WebSocket(`ws://${location.host}/ws/chat`);
|
||||
chatWs.onmessage = (ev) => appendChat("KI", ev.data, "text-blue-400 font-bold");
|
||||
chatWs.onmessage = (ev) => appendChat("J.A.R.V.I.S.", ev.data, "text-blue-400 font-bold");
|
||||
initChat(chatWs);
|
||||
|
||||
window.appendChat = function(user, msg, classes) {
|
||||
const win = document.getElementById('chat-window');
|
||||
let formattedMsg = (user === "KI") ? marked.parse(msg) : msg;
|
||||
let formattedMsg = (user === "J.A.R.V.I.S.") ? marked.parse(msg) : msg;
|
||||
win.innerHTML += `<div class="mb-4"><span class="${classes} block mb-1 text-[10px] uppercase">${user}</span><div class="markdown-content text-slate-300 text-sm">${formattedMsg}</div></div>`;
|
||||
win.scrollTop = win.scrollHeight;
|
||||
};
|
||||
@@ -431,10 +518,26 @@
|
||||
if (window.termWs) window.termWs.close();
|
||||
if (termDataDisposable) termDataDisposable.dispose();
|
||||
term.clear();
|
||||
fitAddon.fit(); // Einmal anpassen beim Öffnen
|
||||
|
||||
window.termWs = new WebSocket(`ws://${location.host}/ws/terminal/${ip}`);
|
||||
|
||||
window.termWs.onopen = () => {
|
||||
// Initiale Größe nach dem Verbinden senden
|
||||
window.termWs.send(JSON.stringify({
|
||||
type: "resize",
|
||||
cols: term.cols,
|
||||
rows: term.rows
|
||||
}));
|
||||
};
|
||||
|
||||
window.termWs.onmessage = (ev) => term.write(ev.data);
|
||||
|
||||
termDataDisposable = term.onData(data => {
|
||||
if (window.termWs?.readyState === WebSocket.OPEN) window.termWs.send(data);
|
||||
if (window.termWs?.readyState === WebSocket.OPEN) {
|
||||
// Normale Tastatureingaben senden wir direkt
|
||||
window.termWs.send(data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user