#!/usr/bin/env bash # Startet den LocalAI-Stack, wartet bis er bereit ist und zeigt die Verbindungsdaten. # # ./scripts/start.sh starten + Chat-Modell vorladen # ./scripts/start.sh --no-preload nur starten # ./scripts/start.sh --model glm-4.7-flash anderes Chat-Modell vorladen # ./scripts/start.sh --show-key API-Key im Klartext ausgeben # ./scripts/start.sh --pull Basis-Images aktualisieren und neu bauen set -euo pipefail cd "$(dirname "$(readlink -f "$0")")/.." PRELOAD=1 MODEL="qwen3.5-35b-a3b" SHOW_KEY=0 PULL=0 while [[ $# -gt 0 ]]; do case "$1" in --no-preload) PRELOAD=0 ;; --model) MODEL="$2"; shift ;; --show-key) SHOW_KEY=1 ;; --pull) PULL=1 ;; -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unbekannte Option: $1" >&2; exit 2 ;; esac shift done if [[ -t 1 ]]; then B=$'\e[1m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m'; else B= G= Y= R= N=; fi ok() { echo "${G}✔${N} $*"; } warn() { echo "${Y}!${N} $*"; } die() { echo "${R}✘${N} $*" >&2; exit 1; } step() { echo; echo "${B}== $*${N}"; } # ---------------------------------------------------------------- Prüfungen step "Prüfungen" command -v docker >/dev/null || die "docker nicht installiert" docker info >/dev/null 2>&1 || die "Docker-Daemon läuft nicht oder keine Rechte (Gruppe 'docker'?)" docker compose version >/dev/null 2>&1 || die "docker compose (v2) fehlt" ok "Docker $(docker version --format '{{.Server.Version}}')" [[ -e /dev/kfd && -d /dev/dri ]] || die "/dev/kfd oder /dev/dri fehlt – AMD-GPU-Treiber (amdgpu/ROCm) prüfen" ok "GPU-Geräte vorhanden" if [[ ! -f .env ]]; then warn ".env fehlt – wird aus .env.example erzeugt" cp .env.example .env sed -i "s/^LLM_API_KEY=.*/LLM_API_KEY=$(openssl rand -hex 32)/" .env sed -i "s/^WEBUI_SECRET_KEY=.*/WEBUI_SECRET_KEY=$(openssl rand -hex 32)/" .env sed -i "s/^HOST_UID=.*/HOST_UID=$(id -u)/; s/^HOST_GID=.*/HOST_GID=$(id -g)/" .env rg=$(getent group render | cut -d: -f3 || true); vg=$(getent group video | cut -d: -f3 || true) [[ -n "$rg" ]] && sed -i "s/^RENDER_GID=.*/RENDER_GID=$rg/" .env [[ -n "$vg" ]] && sed -i "s/^VIDEO_GID=.*/VIDEO_GID=$vg/" .env chmod 600 .env warn "Bitte ALLOWED_NETS in .env prüfen (LAN-/NetBird-Netz)" fi # ältere .env ohne Web-Chat-Einträge ergänzen if ! grep -q '^WEBUI_SECRET_KEY=' .env; then printf '\nWEBUI_PORT=8801\nWEBUI_BACKEND_PORT=3000\nWEBUI_SECRET_KEY=%s\n' "$(openssl rand -hex 32)" >> .env warn "Web-Chat-Einträge (WEBUI_*) in .env ergänzt" fi set -a; . ./.env; set +a LISTEN_PORT="${LISTEN_PORT:-8800}" BACKEND_PORT="${BACKEND_PORT:-8080}" WEBUI_PORT="${WEBUI_PORT:-8801}" WEBUI_BACKEND_PORT="${WEBUI_BACKEND_PORT:-3000}" [[ -n "${LLM_API_KEY:-}" && "$LLM_API_KEY" != "CHANGE_ME" ]] || die "LLM_API_KEY in .env setzen (openssl rand -hex 32)" [[ -n "${WEBUI_SECRET_KEY:-}" && "$WEBUI_SECRET_KEY" != "CHANGE_ME" ]] || die "WEBUI_SECRET_KEY in .env setzen (openssl rand -hex 32)" ok ".env geladen" # Ports dürfen nur von unserem eigenen Stack belegt sein running=$(docker compose ps --status running -q 2>/dev/null | wc -l) if [[ "$running" -eq 0 ]]; then for p in "$LISTEN_PORT" "$BACKEND_PORT" "$WEBUI_PORT" "$WEBUI_BACKEND_PORT"; do if ss -Htln "sport = :$p" | grep -q .; then die "Port $p ist bereits belegt: $(ss -Htlnp "sport = :$p" | awk '{print $NF}' | head -1) – in .env ändern" fi done ok "Ports $LISTEN_PORT, $BACKEND_PORT, $WEBUI_PORT und $WEBUI_BACKEND_PORT frei" fi if systemctl is-active --quiet ollama 2>/dev/null; then warn "Host-Ollama läuft noch (belegt ggf. VRAM, lauscht auf dem Netz) → sudo systemctl disable --now ollama" fi if command -v ufw >/dev/null && systemctl is-active --quiet ufw 2>/dev/null; then for p in "$LISTEN_PORT" "$WEBUI_PORT"; do if [[ -r /etc/ufw/user.rules ]] && ! grep -q "dport $p" /etc/ufw/user.rules; then warn "Keine ufw-Freigabe für Port $p gefunden → sudo ./scripts/firewall-ufw.sh" fi done fi mkdir -p models free_gb=$(df -BG --output=avail models | tail -1 | tr -dc '0-9') (( free_gb < 30 )) && warn "Nur ${free_gb} GB frei – Modell-Downloads brauchen bis zu 25 GB" # ------------------------------------------------------------------- Start step "Starte Container" if (( PULL )); then docker compose pull --ignore-buildable docker compose build --pull fi # baut das llama-swap-Image (ROCm + whisper/sd-server), falls es noch fehlt docker compose up -d --build --remove-orphans step "Warte auf Bereitschaft" for i in $(seq 1 60); do curl -fsS -m 2 "http://127.0.0.1:$BACKEND_PORT/health" >/dev/null 2>&1 && break (( i == 60 )) && die "llama-swap antwortet nicht – docker compose logs llama-swap" sleep 1 done ok "llama-swap bereit" for i in $(seq 1 30); do code=$(curl -s -o /dev/null -m 2 -w '%{http_code}' "http://127.0.0.1:$LISTEN_PORT/v1/models" || true) [[ "$code" == "401" ]] && break (( i == 30 )) && die "Caddy antwortet nicht wie erwartet (HTTP $code) – docker compose logs caddy" sleep 1 done code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' -H "Authorization: Bearer $LLM_API_KEY" "http://127.0.0.1:$LISTEN_PORT/v1/models") [[ "$code" == "200" ]] || die "API mit Key liefert HTTP $code" ok "Caddy bereit (Auth aktiv)" # Open WebUI braucht beim ersten Start etwas länger (Datenbank anlegen) for i in $(seq 1 120); do curl -fsS -m 2 "http://127.0.0.1:$WEBUI_PORT/health" >/dev/null 2>&1 && break (( i == 120 )) && die "Web-Chat antwortet nicht – docker compose logs open-webui" sleep 1 done ok "Web-Chat bereit" # ---------------------------------------------------------------- Preload if (( PRELOAD )); then step "Lade Modelle vor (erster Start lädt ggf. von Hugging Face herunter)" preload() { # $1 = Modell, $2 = Endpoint, Rest = curl-Argumente für den Body local m=$1 ep=$2 t0=$SECONDS code; shift 2 printf ' %-22s … ' "$m" code=$(curl -s -o /dev/null -m 3600 -w '%{http_code}' "http://127.0.0.1:$BACKEND_PORT$ep" "$@" || true) if [[ "$code" == "200" ]]; then echo "${G}ok${N} ($((SECONDS - t0)) s)"; else echo "${R}HTTP $code${N}"; fi } json=(-H 'Content-Type: application/json' -d) preload qwen2.5-coder-1.5b /v1/completions "${json[@]}" '{"model":"qwen2.5-coder-1.5b","prompt":"x","max_tokens":1}' preload nomic-embed /v1/embeddings "${json[@]}" '{"model":"nomic-embed","input":"x"}' if [[ -f models/whisper/ggml-large-v3-turbo-q8_0.bin ]]; then wav=$(mktemp --suffix=.wav) python3 -c 'import wave,sys; w=wave.open(sys.argv[1],"wb"); w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000); w.writeframes(b"\0\0"*16000)' "$wav" preload whisper /v1/audio/transcriptions -F model=whisper -F "file=@$wav" rm -f "$wav" else warn "Whisper-Modell fehlt (models/whisper/) – siehe README" fi preload "$MODEL" /v1/chat/completions "${json[@]}" "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1}" fi # ------------------------------------------------------------------ Status step "Status" docker compose ps --format '{{.Service}}: {{.Status}}' | sed 's/^/ /' loaded=$(curl -s -m 5 "http://127.0.0.1:$BACKEND_PORT/running" \ | python3 -c 'import sys,json; print(", ".join(m["model"] for m in json.load(sys.stdin)["running"]) or "-")' 2>/dev/null || echo "?") echo " geladen: $loaded" command -v rocm-smi >/dev/null && rocm-smi --showmeminfo vram 2>/dev/null \ | awk '/GPU\[0\].*Total Memory/{t=$NF} /GPU\[0\].*Used Memory/{u=$NF} END{if(t) printf " VRAM: %.1f / %.1f GB\n", u/1e9, t/1e9}' step "Verbindung" lan_ip=$(ip -4 -o addr show scope global | awk '$2 !~ /^(wt|docker|virbr|br-|veth)/ {split($4,a,"/"); print a[1]; exit}') nb_ip=$(ip -4 -o addr show wt0 2>/dev/null | awk '{split($4,a,"/"); print a[1]}') nb_fqdn=$(netbird status 2>/dev/null | awk '/^FQDN:/{print $2}' || true) echo " API lokal: http://127.0.0.1:$LISTEN_PORT/v1" [[ -n "$lan_ip" ]] && echo " API LAN: http://$lan_ip:$LISTEN_PORT/v1" [[ -n "$nb_ip" ]] && echo " API NetBird: http://$nb_ip:$LISTEN_PORT/v1${nb_fqdn:+ (http://$nb_fqdn:$LISTEN_PORT/v1)}" echo " Chat lokal: http://127.0.0.1:$WEBUI_PORT" [[ -n "$lan_ip" ]] && echo " Chat LAN: http://$lan_ip:$WEBUI_PORT" [[ -n "$nb_ip" ]] && echo " Chat NetBird: http://$nb_ip:$WEBUI_PORT${nb_fqdn:+ (http://$nb_fqdn:$WEBUI_PORT)}" if (( SHOW_KEY )); then echo " API-Key: $LLM_API_KEY" else echo " API-Key: ${LLM_API_KEY:0:6}… (vollständig: --show-key)" fi echo " llama-swap: http://127.0.0.1:$BACKEND_PORT/ui (nur lokal)" echo echo " Clients einrichten: docs/opencode.md, docs/continue.md, docs/webchat.md"