#!/usr/bin/env bash # ============================================================================= # Local AI Setup — Ubuntu 24.04 # One script to rule them all. Select what you need, answer questions, walk away. # # Options (checklist at launch): # • Full system setup — Ubuntu apps, security, backups (ubuntu-post-install.sh) # • AI stack — Ollama · Open WebUI · RAG · MCP · ChromaDB · SearXNG # Gitea · InvokeAI · Portainer # • Kiwix — Offline Wikipedia, Stack Overflow, Arch Wiki, etc. # # Usage: # ./laptop_full_setup.sh — interactive (asks everything upfront) # ./laptop_full_setup.sh --force — overwrite existing config files too # # GPU: Ollama auto-detects VRAM — works with any NVIDIA GPU # ============================================================================= set -euo pipefail # ── colours ─────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${CYAN}[..]${NC} $*"; } ok() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[!!]${NC} $*"; } die() { echo -e "${RED}[XX]${NC} $*" >&2; exit 1; } section() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; } # ── args ────────────────────────────────────────────────────────────────────── FORCE=false for arg in "$@"; do [[ "$arg" == "--force" ]] && FORCE=true; done # ── config ──────────────────────────────────────────────────────────────────── BASE="$HOME/docker/ai-stack" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' \ || hostname -I | awk '{print $1}') [[ -z "$LOCAL_IP" ]] && read -rp "Enter your LAN IP: " LOCAL_IP # Models (defaults, may be adjusted below based on VRAM) EMBED_MODEL="nomic-embed-text" CHAT_MODEL="qwen2.5:14b" CODE_MODEL="qwen2.5-coder:7b" FAST_MODEL="qwen2.5:7b" # ── detect GPU ──────────────────────────────────────────────────────────────── VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ | head -1 | awk '{printf "%d", $1/1024}' 2>/dev/null || echo "0") GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo "None") if [[ "$VRAM_GB" -ge 14 ]]; then CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:14b" GPU_TIER="16GB VRAM — 14B models" elif [[ "$VRAM_GB" -ge 8 ]]; then CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="8GB VRAM — 14B chat, 7B code" elif [[ "$VRAM_GB" -ge 4 ]]; then CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="6GB VRAM — 7B models" elif [[ "$VRAM_GB" -gt 0 ]]; then CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="${VRAM_GB}GB VRAM — 7B models" else CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="CPU only — 7B models (slow)" fi # ── new vs update ───────────────────────────────────────────────────────────── IS_UPDATE=false [[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true # ============================================================================= # QUESTIONS UPFRONT — answer everything, then walk away # ============================================================================= section "Local AI Stack — Setup Wizard" echo "" echo -e " ${BOLD}Answer the questions below, then leave it overnight.${NC}" echo -e " Everything will install and download while you sleep." echo "" info "Machine : $(hostname)" info "LAN IP : $LOCAL_IP" info "GPU : ${GPU_NAME} (${VRAM_GB}GB VRAM)" $IS_UPDATE && warn "Existing install found. Config files kept unless --force is passed." # ── Q1: What to install — checklist ────────────────────────────────────────── INSTALL_POSTINSTALL=false INSTALL_AI=true INSTALL_KIWIX=true POSTINSTALL_SCRIPT="$SCRIPT_DIR/ubuntu-post-install.sh" HAS_POSTINSTALL=false [[ -f "$POSTINSTALL_SCRIPT" ]] && HAS_POSTINSTALL=true # Build checklist via whiptail if available, else text toggle if command -v whiptail &>/dev/null; then WHIP_ARGS=() $HAS_POSTINSTALL && WHIP_ARGS+=( "postinstall" "Full system setup — Ubuntu apps, security, backups" OFF ) WHIP_ARGS+=( "aistack" "AI stack — Ollama · WebUI · RAG · MCP · Gitea · InvokeAI" ON "kiwix" "Kiwix — Offline Wikipedia, Stack Overflow, Arch Wiki…" ON ) SELECTED=$(whiptail --title "Local AI Setup" \ --checklist "SPACE = toggle TAB = move ENTER = confirm" \ 15 72 "${#WHIP_ARGS[@]}" "${WHIP_ARGS[@]}" 3>&1 1>&2 2>&3) || die "Cancelled." INSTALL_AI=false; INSTALL_KIWIX=false [[ "$SELECTED" == *"postinstall"* ]] && INSTALL_POSTINSTALL=true [[ "$SELECTED" == *"aistack"* ]] && INSTALL_AI=true [[ "$SELECTED" == *"kiwix"* ]] && INSTALL_KIWIX=true else # Text-based toggle list echo "" echo -e " ${BOLD}[1/5] Select what to install${NC}" echo " (Type a number to toggle on/off, then press Enter with no number to confirm)" echo "" SEL_POST=false; SEL_AI=true; SEL_KIWIX=true while true; do echo "" $HAS_POSTINSTALL && printf " [%s] 1. Full system setup — Ubuntu apps, security, backups\n" "$($SEL_POST && echo '*' || echo ' ')" printf " [%s] 2. AI stack — Ollama · WebUI · RAG · MCP · Gitea · InvokeAI\n" "$($SEL_AI && echo '*' || echo ' ')" printf " [%s] 3. Kiwix — Offline Wikipedia, Stack Overflow, docs\n" "$($SEL_KIWIX && echo '*' || echo ' ')" echo "" read -rp " Toggle [number] or Enter to confirm: " T case "$T" in 1) $HAS_POSTINSTALL && { $SEL_POST && SEL_POST=false || SEL_POST=true; } ;; 2) $SEL_AI && SEL_AI=false || SEL_AI=true ;; 3) $SEL_KIWIX && SEL_KIWIX=false || SEL_KIWIX=true ;; "") break ;; esac done INSTALL_POSTINSTALL=$SEL_POST INSTALL_AI=$SEL_AI INSTALL_KIWIX=$SEL_KIWIX fi $INSTALL_AI || $INSTALL_KIWIX || $INSTALL_POSTINSTALL \ || die "Nothing selected — run again and select at least one option." # ── Q1b: SSH keys from GitHub / Launchpad ───────────────────────────────────── SSH_IMPORT_IDS=() # list of "gh:username" or "lp:username" entries if ! $IS_UPDATE || [[ ! -f "$HOME/.ssh/authorized_keys" ]]; then echo "" echo -e " ${BOLD}[SSH] Import SSH public keys? (for passwordless SSH into this machine)${NC}" echo " Pulls your public keys from GitHub or Launchpad and adds them to" echo " ~/.ssh/authorized_keys using ssh-import-id." echo "" echo " Examples: gh:yourusername lp:yourlaunchpadid" echo " Multiple: gh:alice lp:alice" echo "" read -rp " Usernames (or Enter to skip): " SSH_INPUT if [[ -n "$SSH_INPUT" ]]; then read -ra SSH_IMPORT_IDS <<< "$SSH_INPUT" fi fi # ── Q2: Storage for Ollama models ───────────────────────────────────────────── OLLAMA_STORAGE="volume" # "volume" = Docker named volume, else a host path OLLAMA_HOST_PATH="" if $INSTALL_AI; then echo "" echo -e " ${BOLD}[2/6] Where should Ollama models be stored?${NC}" echo " (Models are large — 5-50GB each. A fast SSD or large HDD is ideal.)" echo "" echo " Available mount points with >20GB free:" df -h --output=target,avail,fstype 2>/dev/null \ | awk 'NR>1 && $2~/[0-9]/ { val=$2; unit=substr(val,length(val)); num=substr(val,1,length(val)-1)+0; if ((unit=="G" && num>=20) || unit=="T") print " " $0 }' | head -10 echo "" echo " 1) Docker volume (default — stored in /var/lib/docker/volumes/)" echo " 2) Custom path (e.g. /mnt/ssd/ollama or /data/ollama)" echo "" read -rp " Choice [1]: " STORAGE_CHOICE STORAGE_CHOICE="${STORAGE_CHOICE:-1}" if [[ "$STORAGE_CHOICE" == "2" ]]; then read -rp " Enter full path for Ollama models: " OLLAMA_HOST_PATH OLLAMA_HOST_PATH="${OLLAMA_HOST_PATH%/}" # strip trailing slash [[ -z "$OLLAMA_HOST_PATH" ]] && die "No path entered." OLLAMA_STORAGE="bind" fi fi # ── Q3: Storage for Kiwix ZIMs ──────────────────────────────────────────────── KIWIX_DIR="$BASE/kiwix" # default if $INSTALL_KIWIX; then echo "" echo -e " ${BOLD}[3/6] Where should Kiwix ZIM files be stored?${NC}" echo " (ZIMs are large — Wikipedia alone is ~46GB. Total collection ~130GB.)" echo "" echo " Available mount points with >50GB free:" df -h --output=target,avail,fstype 2>/dev/null \ | awk 'NR>1 && $2~/[0-9]/ { val=$2; unit=substr(val,length(val)); num=substr(val,1,length(val)-1)+0; if ((unit=="G" && num>=50) || unit=="T") print " " $0 }' | head -10 echo "" echo " Default: $KIWIX_DIR" read -rp " Press Enter to use default, or type a different path: " KIWIX_INPUT if [[ -n "$KIWIX_INPUT" ]]; then KIWIX_DIR="${KIWIX_INPUT%/}" fi # ── Q4: Download ZIMs now? ───────────────────────────────────────────────── echo "" echo -e " ${BOLD}[4/6] Download ZIM files? (offline Wikipedia, Stack Overflow, etc.)${NC}" echo " Downloads run in the background — safe to start now and leave overnight." echo " Total: ~130GB (Wikipedia 46GB, Project Gutenberg 60GB, others smaller)" echo "" echo " 1) Yes — download all ZIMs overnight (~130GB)" echo " 2) Select — choose which ZIMs to download" echo " 3) No — skip for now (run ./kiwix_download.sh later)" echo "" read -rp " Choice [3]: " ZIM_CHOICE ZIM_CHOICE="${ZIM_CHOICE:-3}" else ZIM_CHOICE="3" fi # ── Q4b: Firewall (LAN subnet) ──────────────────────────────────────────────── LAN_SUBNET="192.168.1.0/24" if command -v ufw &>/dev/null && { [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; }; then echo "" # Auto-detect likely subnet from current IP AUTO_SUBNET=$(echo "$LOCAL_IP" | awk -F. '{print $1"."$2"."$3".0/24"}') echo -e " ${BOLD}[4b/6] Firewall — allow LAN access to services${NC}" read -rp " LAN subnet [${AUTO_SUBNET}]: " LAN_INPUT LAN_SUBNET="${LAN_INPUT:-$AUTO_SUBNET}" [[ "$LAN_SUBNET" =~ /[0-9]+$ ]] || LAN_SUBNET="${LAN_SUBNET}/24" fi # ── Q5: Model selection wizard ──────────────────────────────────────────────── PULL_MODELS=false REASON_MODEL="" if $INSTALL_AI; then # speed estimate based on Q4 model size vs available VRAM # no hard limits — just honest labels so the user can choose speed_label() { local mgb="$1" # approximate Q4 size in GB if [[ "$VRAM_GB" -eq 0 ]]; then printf "CPU only — very slow" elif (( mgb <= VRAM_GB )); then printf "✓ fast — fully in VRAM" elif (( mgb <= VRAM_GB + 2 )); then printf "~ good — fits with small overhang (~reading speed)" elif (( mgb <= VRAM_GB + 8 )); then printf "✗ slow — partial CPU offload" else printf "✗ very slow — heavy CPU offload" fi } echo "" echo -e " ${BOLD}[5/6] Model selection${NC}" echo " GPU: ${GPU_NAME:-None} (${VRAM_GB}GB VRAM)" echo "" echo " Origin preference:" echo " 1) Western-only — Codestral (Mistral 🇫🇷) · Phi4 (Microsoft 🇺🇸) · Mistral 7B" echo " 2) Performance-first — Qwen2.5 · Qwen2.5-Coder (Chinese, top benchmarks)" echo " 3) Mixed — Western for chat/reasoning, Qwen for coding only" echo " 4) Custom — enter model names manually" echo "" read -rp " Choice [1]: " MODEL_PREF MODEL_PREF="${MODEL_PREF:-1}" # Q4 approximate sizes in GB: 7B=4, 13-15B=9, 22B=13, 32B=19, 70-72B=41 echo "" echo " Size tier — estimated speed on your ${VRAM_GB}GB GPU:" echo " (No hard limits — Ollama uses all available VRAM automatically)" echo "" printf " %-8s %-42s %s\n" "Tier" "Models" "Speed on your system" printf " %-8s %-42s %s\n" "--------" "------------------------------------------" "--------------------" case "$MODEL_PREF" in 1) # Western printf " %-8s %-42s %s\n" "7B" "mistral:7b + codellama:7b" "$(speed_label 4)" printf " %-8s %-42s %s\n" "14B" "phi4:14b + starcoder2:15b" "$(speed_label 9)" printf " %-8s %-42s %s\n" "22B" "phi4:14b + codestral:22b" "$(speed_label 13)" printf " %-8s %-42s %s\n" "70B" "llama3.3:70b + codestral:22b" "$(speed_label 41)" ;; 2) # Performance printf " %-8s %-42s %s\n" "7B" "qwen2.5:7b + qwen2.5-coder:7b" "$(speed_label 4)" printf " %-8s %-42s %s\n" "14B" "qwen2.5:14b + qwen2.5-coder:14b" "$(speed_label 9)" printf " %-8s %-42s %s\n" "32B" "qwen2.5:14b + qwen2.5-coder:32b" "$(speed_label 19)" printf " %-8s %-42s %s\n" "72B" "qwen2.5:72b + qwen2.5-coder:32b" "$(speed_label 41)" ;; 3) # Mixed printf " %-8s %-42s %s\n" "7B" "mistral:7b + qwen2.5-coder:7b" "$(speed_label 4)" printf " %-8s %-42s %s\n" "14B" "phi4:14b + qwen2.5-coder:14b" "$(speed_label 9)" printf " %-8s %-42s %s\n" "32B" "phi4:14b + qwen2.5-coder:32b" "$(speed_label 19)" printf " %-8s %-42s %s\n" "70B" "llama3.3:70b + qwen2.5-coder:32b" "$(speed_label 41)" ;; esac # VRAM-based recommendation (soft — shown as suggestion only) if [[ "$VRAM_GB" -ge 20 ]]; then REC_TIER="32B" elif [[ "$VRAM_GB" -ge 12 ]]; then REC_TIER="22B" elif [[ "$VRAM_GB" -ge 6 ]]; then REC_TIER="14B" else REC_TIER="7B" fi # performance pref has no 22B tier [[ "$MODEL_PREF" == "2" && "$REC_TIER" == "22B" ]] && REC_TIER="32B" echo "" if [[ "$MODEL_PREF" == "4" ]]; then # Custom — free-form entry echo " Current defaults: fast=$FAST_MODEL chat=$CHAT_MODEL code=$CODE_MODEL" echo " Press Enter on any line to keep the default shown." echo "" read -rp " Fast/chat model [$FAST_MODEL]: " _in; FAST_MODEL="${_in:-$FAST_MODEL}" read -rp " Smart chat model [$CHAT_MODEL]: " _in; CHAT_MODEL="${_in:-$CHAT_MODEL}" read -rp " Code model [$CODE_MODEL]: " _in; CODE_MODEL="${_in:-$CODE_MODEL}" read -rp " Reasoning model (Enter to skip): " REASON_MODEL else read -rp " Choose tier [$REC_TIER]: " TIER_PICK TIER_PICK="${TIER_PICK:-$REC_TIER}" case "${MODEL_PREF}:${TIER_PICK}" in # Western 1:7B) FAST_MODEL="mistral:7b"; CHAT_MODEL="mistral:7b"; CODE_MODEL="codellama:7b"; REASON_MODEL="" ;; 1:14B) FAST_MODEL="mistral:7b"; CHAT_MODEL="phi4:14b"; CODE_MODEL="starcoder2:15b"; REASON_MODEL="phi4:14b" ;; 1:22B) FAST_MODEL="mistral:7b"; CHAT_MODEL="phi4:14b"; CODE_MODEL="codestral:22b"; REASON_MODEL="phi4:14b" ;; 1:70B) FAST_MODEL="mistral:7b"; CHAT_MODEL="llama3.3:70b"; CODE_MODEL="codestral:22b"; REASON_MODEL="llama3.3:70b" ;; # Performance-first 2:7B) FAST_MODEL="qwen2.5:7b"; CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b"; REASON_MODEL="" ;; 2:14B) FAST_MODEL="qwen2.5:7b"; CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:14b"; REASON_MODEL="deepseek-r1:14b" ;; 2:32B) FAST_MODEL="qwen2.5:7b"; CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:32b"; REASON_MODEL="deepseek-r1:14b" ;; 2:72B) FAST_MODEL="qwen2.5:7b"; CHAT_MODEL="qwen2.5:72b"; CODE_MODEL="qwen2.5-coder:32b"; REASON_MODEL="deepseek-r1:14b" ;; # Mixed 3:7B) FAST_MODEL="mistral:7b"; CHAT_MODEL="mistral:7b"; CODE_MODEL="qwen2.5-coder:7b"; REASON_MODEL="" ;; 3:14B) FAST_MODEL="mistral:7b"; CHAT_MODEL="phi4:14b"; CODE_MODEL="qwen2.5-coder:14b"; REASON_MODEL="phi4:14b" ;; 3:32B) FAST_MODEL="mistral:7b"; CHAT_MODEL="phi4:14b"; CODE_MODEL="qwen2.5-coder:32b"; REASON_MODEL="phi4:14b" ;; 3:70B) FAST_MODEL="mistral:7b"; CHAT_MODEL="llama3.3:70b"; CODE_MODEL="qwen2.5-coder:32b"; REASON_MODEL="llama3.3:70b" ;; *) warn "Unrecognised tier '$TIER_PICK' — keeping detected defaults" ;; esac fi echo "" echo " Models selected:" printf " %-16s %s\n" "Fast chat:" "$FAST_MODEL" printf " %-16s %s\n" "Smart chat:" "$CHAT_MODEL" printf " %-16s %s\n" "Code:" "$CODE_MODEL" [[ -n "$REASON_MODEL" ]] && printf " %-16s %s\n" "Reasoning:" "$REASON_MODEL" printf " %-16s %s\n" "Embed (RAG):" "$EMBED_MODEL" echo "" read -rp " Download these models now? [Y/n]: " DO_PULL [[ "${DO_PULL,,}" != "n" ]] && PULL_MODELS=true fi # ── Summary ─────────────────────────────────────────────────────────────────── echo "" echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BOLD} Setup plan — starting now:${NC}" echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" $INSTALL_POSTINSTALL && echo " ✓ Full system setup (ubuntu-post-install.sh — interactive)" $INSTALL_AI && echo " ✓ AI stack (Ollama · WebUI · RAG · MCP · Gitea · InvokeAI · Portainer)" [[ "${#SSH_IMPORT_IDS[@]}" -gt 0 ]] && echo " ✓ SSH keys → ${SSH_IMPORT_IDS[*]}" $INSTALL_KIWIX && echo " ✓ Kiwix offline library" if $INSTALL_AI; then if [[ "$OLLAMA_STORAGE" == "bind" ]]; then echo " ✓ Ollama models → $OLLAMA_HOST_PATH" else echo " ✓ Ollama models → Docker volume (default)" fi fi $INSTALL_KIWIX && echo " ✓ Kiwix ZIMs → $KIWIX_DIR" $PULL_MODELS && echo " ✓ Pull models : $EMBED_MODEL + $FAST_MODEL + $CHAT_MODEL + $CODE_MODEL${REASON_MODEL:+ + $REASON_MODEL}" [[ "$ZIM_CHOICE" == "1" ]] && echo " ✓ Download all ZIMs in background (~130GB)" [[ "$ZIM_CHOICE" == "2" ]] && echo " ✓ Select ZIMs to download (prompted after stack starts)" echo "" read -rp " Proceed? [Y/n]: " CONFIRM [[ "${CONFIRM,,}" == "n" ]] && echo "Aborted." && exit 0 echo "" # ── helper: write only if missing (or --force) ──────────────────────────────── # Usage: write_if_new /path/to/file << 'EOF' ... EOF write_if_new() { local dest="$1" local content; content=$(cat) if [[ ! -f "$dest" ]] || $FORCE; then printf '%s\n' "$content" > "$dest" ok "Wrote $(basename "$dest")" else info "Kept $(basename "$dest") (--force to overwrite)" fi } # ============================================================================= # Run full system post-install first (sets up Docker, security, base services) # ============================================================================= if $INSTALL_POSTINSTALL; then section "Full System Setup" if [[ ! -f "$POSTINSTALL_SCRIPT" ]]; then die "ubuntu-post-install.sh not found at $POSTINSTALL_SCRIPT" fi info "Launching ubuntu-post-install.sh in normal install mode..." echo "" bash "$POSTINSTALL_SCRIPT" echo "" ok "System setup complete — continuing with selected components..." echo "" fi # ============================================================================= section "Prerequisites" # ============================================================================= # ── Docker (always check — required whether new install or update) ───────────── if ! command -v docker &>/dev/null; then info "Installing Docker..." curl -fsSL https://get.docker.com | sh sudo usermod -aG docker "$USER" warn "Added $USER to docker group — changes take effect on next login." warn "If docker commands fail, run: newgrp docker" else ok "Docker: $(docker --version | sed 's/Docker version //')" fi # Ensure Docker Compose plugin is present (included with modern Docker) if ! docker compose version &>/dev/null; then info "Installing docker-compose-plugin..." sudo apt-get install -y docker-compose-plugin fi ok "Docker Compose: $(docker compose version --short 2>/dev/null || echo 'ok')" # ── NVIDIA Container Toolkit ─────────────────────────────────────────────────── if command -v nvidia-smi &>/dev/null; then if ! dpkg -l 2>/dev/null | grep -q nvidia-container-toolkit; then info "Installing NVIDIA Container Toolkit..." curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ | sudo gpg --dearmor --yes \ -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list sudo apt-get update -qq sudo apt-get install -y nvidia-container-toolkit sudo nvidia-ctk runtime configure --runtime=docker sudo systemctl restart docker ok "NVIDIA Container Toolkit installed" else ok "NVIDIA Container Toolkit: already present" fi else info "No NVIDIA GPU detected — Ollama will run on CPU" fi # ── ripgrep (used by MCP search_code tool) ───────────────────────────────────── if ! command -v rg &>/dev/null; then sudo apt-get install -y ripgrep ok "ripgrep installed" fi # ── SSH key import from GitHub / Launchpad ───────────────────────────────────── if [[ "${#SSH_IMPORT_IDS[@]}" -gt 0 ]]; then section "SSH Keys" if ! command -v ssh-import-id &>/dev/null; then info "Installing ssh-import-id..." sudo apt-get install -y ssh-import-id fi mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" for id in "${SSH_IMPORT_IDS[@]}"; do info "Importing SSH keys: $id" ssh-import-id "$id" && ok "Keys imported: $id" || warn "Failed to import: $id" done fi # ============================================================================= section "Directories" # ============================================================================= for d in papers repos workspace index searxng invokeai-data invokeai-outputs \ gitea portainer-data logs; do mkdir -p "$BASE/$d" done # Kiwix ZIM dir — may be on a different drive mkdir -p "$KIWIX_DIR" # Ollama bind-mount dir (if using custom path) [[ "$OLLAMA_STORAGE" == "bind" ]] && mkdir -p "$OLLAMA_HOST_PATH" ok "Directories ready" # ============================================================================= if $INSTALL_AI; then section "Server Files" # ============================================================================= # Copy Python servers from repo (always update — they are version-controlled) cp "$SCRIPT_DIR/server.py" "$BASE/server.py" && ok "server.py" cp "$SCRIPT_DIR/mcp_server.py" "$BASE/mcp_server.py" && ok "mcp_server.py" # RAG dependencies cat > "$BASE/requirements.txt" << 'REQ' fastapi uvicorn[standard] httpx pydantic chromadb pypdf watchdog python-multipart REQ ok "requirements.txt" # MCP server dependencies cat > "$BASE/mcp_requirements.txt" << 'REQ' mcp[cli] fastapi uvicorn[standard] httpx REQ ok "mcp_requirements.txt" fi # INSTALL_AI # ============================================================================= if $INSTALL_AI; then section "SearXNG Config" # ============================================================================= write_if_new "$BASE/searxng/settings.yml" << SEARXNG use_default_settings: true general: instance_name: "Local Search" server: secret_key: "$(openssl rand -hex 32)" limiter: false search: safe_search: 0 default_lang: "en" formats: [html, json] SEARXNG fi # INSTALL_AI # ============================================================================= section ".env File" # ============================================================================= # Never overwrite — this is where users store their tokens if [[ ! -f "$BASE/.env" ]]; then cat > "$BASE/.env" << ENV # Local AI Stack — API Tokens # Edit this file, then restart: bash $BASE/start.sh # Gitea — generate at http://$LOCAL_IP:3001/user/settings/applications GITEA_TOKEN=your-gitea-token-here # GitHub — optional, for GitHub API access via MCP GITHUB_TOKEN=your-github-token-here ENV ok "Created .env — add your tokens before using MCP Gitea/GitHub tools" else info "Kept .env (never overwritten)" fi # ============================================================================= section "Docker Compose" # ============================================================================= # Build ollama volume line based on storage choice if [[ "$OLLAMA_STORAGE" == "bind" ]]; then OLLAMA_VOLUME_LINE=" - ${OLLAMA_HOST_PATH}:/root/.ollama" OLLAMA_VOLUMES_DECL="" else OLLAMA_VOLUME_LINE=" - ollama-models:/root/.ollama" OLLAMA_VOLUMES_DECL=" ollama-models:" fi # Always written — it's the stack definition and safe to update cat > "$BASE/docker-compose.yml" << COMPOSE # Local AI Stack — generated $(date '+%Y-%m-%d') # Edit \$BASE/.env to add API tokens. # GPU: OLLAMA_NUM_GPU=999 means "use all available VRAM" — auto-adapts to any GPU. services: # ── Ollama — LLM inference ────────────────────────────────────────────────── ollama: image: ollama/ollama:latest container_name: ollama restart: unless-stopped ports: - "0.0.0.0:11434:11434" volumes: ${OLLAMA_VOLUME_LINE} environment: - OLLAMA_NUM_GPU=999 # use all available VRAM (auto-detects GPU size) - OLLAMA_NUM_CTX=8192 # lower to 4096 if you hit OOM - OLLAMA_KEEP_ALIVE=24h - OLLAMA_MAX_LOADED_MODELS=1 deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] healthcheck: test: ["CMD", "ollama", "list"] interval: 30s timeout: 10s retries: 5 # ── Open WebUI — Chat interface ───────────────────────────────────────────── open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui restart: unless-stopped ports: - "0.0.0.0:3000:8080" volumes: - open-webui-data:/app/backend/data environment: - OLLAMA_BASE_URL=http://ollama:11434 - OPENAI_API_BASE_URL=http://rag-server:8001/v1 - OPENAI_API_KEY=local-rag-key - ENABLE_OPENAI_API=true - ENABLE_RAG_WEB_SEARCH=true - RAG_WEB_SEARCH_ENGINE=searxng - SEARXNG_QUERY_URL=http://searxng:8080/search?q=&format=json - WEBUI_AUTH=false depends_on: ollama: condition: service_healthy # ── ChromaDB — Vector store ───────────────────────────────────────────────── chromadb: image: chromadb/chroma:latest container_name: chromadb restart: unless-stopped ports: - "0.0.0.0:8000:8000" volumes: - $BASE/index:/chroma/chroma environment: - IS_PERSISTENT=TRUE - ANONYMIZED_TELEMETRY=FALSE # ── RAG Server — code-aware retrieval ────────────────────────────────────── rag-server: image: python:3.11-slim container_name: rag-server restart: unless-stopped ports: - "0.0.0.0:8001:8001" volumes: - $BASE/papers:/papers - $BASE/repos:/repos - $BASE/index:/index - $BASE/server.py:/app/server.py - $BASE/requirements.txt:/app/requirements.txt working_dir: /app environment: - OLLAMA_URL=http://ollama:11434 - CHROMA_URL=http://chromadb:8000 - EMBED_MODEL=nomic-embed-text - CHAT_MODEL=qwen2.5:14b - PAPERS_DIR=/papers - REPOS_DIR=/repos command: > bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git && pip install --no-cache-dir -r requirements.txt && uvicorn server:app --host 0.0.0.0 --port 8001" depends_on: chromadb: condition: service_started ollama: condition: service_healthy # ── MCP Server — Claude Code-equivalent tools ─────────────────────────────── # Connect via: http://$LOCAL_IP:8002/sse # Add to Claude Code: claude mcp add local http://$LOCAL_IP:8002/sse mcp-server: image: python:3.11-slim container_name: mcp-server restart: unless-stopped ports: - "0.0.0.0:8002:8002" volumes: - $BASE/workspace:/workspace - $BASE/repos:/repos - $BASE/mcp_server.py:/app/mcp_server.py - $BASE/mcp_requirements.txt:/app/mcp_requirements.txt working_dir: /app env_file: $BASE/.env environment: - WORKSPACE_DIR=/workspace - REPOS_DIR=/repos - GITEA_URL=http://gitea:3000 - RAG_URL=http://rag-server:8001 command: > bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git ripgrep && pip install --no-cache-dir -r mcp_requirements.txt && python mcp_server.py" depends_on: - rag-server # ── SearXNG — Private web search ─────────────────────────────────────────── searxng: image: searxng/searxng:latest container_name: searxng restart: unless-stopped ports: - "0.0.0.0:8888:8080" volumes: - $BASE/searxng:/etc/searxng cap_drop: [ALL] cap_add: [CHOWN, SETGID, SETUID] # ── Kiwix — Offline Wikipedia/docs ───────────────────────────────────────── kiwix: image: ghcr.io/kiwix/kiwix-serve:latest container_name: kiwix restart: unless-stopped ports: - "0.0.0.0:8181:8080" volumes: - $KIWIX_DIR:/data command: "*.zim" # ── Gitea — Self-hosted Git ───────────────────────────────────────────────── gitea: image: gitea/gitea:latest container_name: gitea restart: unless-stopped ports: - "0.0.0.0:3001:3000" - "0.0.0.0:2222:22" volumes: - $BASE/gitea:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro environment: - USER_UID=1000 - USER_GID=1000 - GITEA__database__DB_TYPE=sqlite3 - GITEA__database__PATH=/data/gitea/gitea.db - GITEA__webhook__ALLOWED_HOST_LIST=rag-server,mcp-server # ── InvokeAI — Image generation ──────────────────────────────────────────── invokeai: image: ghcr.io/invoke-ai/invokeai:latest container_name: invokeai restart: unless-stopped ports: - "0.0.0.0:9090:9090" volumes: - invokeai-models:/invokeai/models - $BASE/invokeai-outputs:/invokeai/outputs - $BASE/invokeai-data:/invokeai/databases environment: - INVOKEAI_HOST=0.0.0.0 - INVOKEAI_PORT=9090 - INVOKEAI_PRECISION=float16 deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] # ── Portainer — Docker management UI ─────────────────────────────────────── portainer: image: portainer/portainer-ce:latest container_name: portainer restart: unless-stopped ports: - "0.0.0.0:9000:9000" - "0.0.0.0:9443:9443" volumes: - /var/run/docker.sock:/var/run/docker.sock - $BASE/portainer-data:/data volumes: ${OLLAMA_VOLUMES_DECL} open-webui-data: invokeai-models: COMPOSE ok "docker-compose.yml written" # ============================================================================= section "Firewall (UFW)" # ============================================================================= if command -v ufw &>/dev/null; then if [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; then for port_comment in \ "3000:Open WebUI" "11434:Ollama" "8001:RAG Server" \ "8002:MCP Server" "8000:ChromaDB" "8888:SearXNG" \ "8181:Kiwix" "3001:Gitea" "2222:Gitea SSH" \ "9090:InvokeAI" "9000:Portainer" "9443:Portainer S"; do port="${port_comment%%:*}" comment="${port_comment##*:}" sudo ufw allow from "$LAN_SUBNET" to any port "$port" proto tcp \ comment "$comment" > /dev/null done sudo ufw reload > /dev/null ok "UFW rules set for $LAN_SUBNET" touch "$BASE/.ufw-done" else info "UFW rules already set (--force to redo)" fi else warn "ufw not found — skipping firewall config" fi # ============================================================================= section "Helper Scripts" # ============================================================================= cat > "$BASE/start.sh" << STARTSH #!/bin/bash cd "$BASE" echo "Pulling latest images..." docker compose pull --quiet docker compose up -d echo "" echo " Open WebUI → http://$LOCAL_IP:3000" echo " InvokeAI → http://$LOCAL_IP:9090" echo " SearXNG → http://$LOCAL_IP:8888" echo " Kiwix → http://$LOCAL_IP:8181" echo " Gitea → http://$LOCAL_IP:3001" echo " RAG Health → http://$LOCAL_IP:8001/health" echo " MCP SSE → http://$LOCAL_IP:8002/sse" echo " Portainer → https://$LOCAL_IP:9443" echo "" echo " Workspace → $BASE/workspace/" echo " Drop PDFs → $BASE/papers/" echo " Images out → $BASE/invokeai-outputs/" echo "" echo " Claude Code MCP:" echo " claude mcp add local http://$LOCAL_IP:8002/sse" STARTSH chmod +x "$BASE/start.sh" ok "start.sh" cat > "$BASE/stop.sh" << STOPSH #!/bin/bash cd "$BASE" docker compose down echo "All services stopped." STOPSH chmod +x "$BASE/stop.sh" ok "stop.sh" cat > "$BASE/status.sh" << 'STATUSSH' #!/bin/bash echo "=== GPU ===" nvidia-smi --query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total \ --format=csv,noheader 2>/dev/null || echo "(no GPU)" echo "" echo "=== Containers ===" docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" echo "" echo "=== Ollama models ===" docker exec ollama ollama ps 2>/dev/null || echo "(not running)" echo "" echo "=== RAG ===" curl -s http://localhost:8001/health | python3 -m json.tool 2>/dev/null \ || echo "(not running)" echo "" echo "=== MCP ===" curl -s http://localhost:8002/health 2>/dev/null || echo "(not running)" echo "" echo "=== Disk ===" du -sh ~/docker/ai-stack/*/ 2>/dev/null STATUSSH chmod +x "$BASE/status.sh" ok "status.sh" cat > "$BASE/pull-models.sh" << PULLSH #!/bin/bash # Models chosen at install time — re-run setup to change selection EMBED_MODEL="$EMBED_MODEL" FAST_MODEL="$FAST_MODEL" CHAT_MODEL="$CHAT_MODEL" CODE_MODEL="$CODE_MODEL" REASON_MODEL="$REASON_MODEL" echo "Waiting for Ollama..." until docker exec ollama ollama list &>/dev/null; do sleep 3; done echo "Ollama ready." echo "" echo "Pulling embed model (RAG — required)..." docker exec ollama ollama pull "\$EMBED_MODEL" echo "Pulling fast chat model..." docker exec ollama ollama pull "\$FAST_MODEL" echo "Pulling smart chat model..." [[ "\$CHAT_MODEL" != "\$FAST_MODEL" ]] && docker exec ollama ollama pull "\$CHAT_MODEL" echo "Pulling code model..." docker exec ollama ollama pull "\$CODE_MODEL" if [[ -n "\$REASON_MODEL" && "\$REASON_MODEL" != "\$CHAT_MODEL" ]]; then echo "Pulling reasoning model..." docker exec ollama ollama pull "\$REASON_MODEL" fi echo "" echo "Done. Installed models:" docker exec ollama ollama list PULLSH chmod +x "$BASE/pull-models.sh" ok "pull-models.sh" write_if_new "$BASE/Caddyfile.example" << CADDY # Caddy2 reverse proxy — copy to your proxy machine # Replace yourdomain.com with your actual domain webui.yourdomain.com { reverse_proxy $(hostname).local:3000 } invokeai.yourdomain.com { reverse_proxy $(hostname).local:9090 } search.yourdomain.com { reverse_proxy $(hostname).local:8888 } git.yourdomain.com { reverse_proxy $(hostname).local:3001 } kiwix.yourdomain.com { reverse_proxy $(hostname).local:8181 } rag.yourdomain.com { reverse_proxy $(hostname).local:8001 } mcp.yourdomain.com { reverse_proxy $(hostname).local:8002 } portainer.yourdomain.com { reverse_proxy https://$(hostname).local:9443 { transport http { tls_insecure_skip_verify } } } CADDY # ============================================================================= section "Systemd Auto-Start" # ============================================================================= sudo tee /etc/systemd/system/local-ai.service > /dev/null << SYSD [Unit] Description=Local AI Stack After=docker.service network-online.target Requires=docker.service [Service] Type=oneshot RemainAfterExit=yes User=$USER WorkingDirectory=$BASE ExecStart=/bin/bash $BASE/start.sh ExecStop=/bin/bash $BASE/stop.sh TimeoutStartSec=300 [Install] WantedBy=multi-user.target SYSD sudo systemctl daemon-reload sudo systemctl enable local-ai.service ok "Systemd service enabled (local-ai.service)" # ============================================================================= section "Starting Stack" # ============================================================================= cd "$BASE" info "Pulling images (this takes a few minutes on first run)..." COMPOSE_SERVICES="" $INSTALL_AI && COMPOSE_SERVICES+=" ollama open-webui chromadb rag-server mcp-server searxng gitea invokeai portainer" $INSTALL_KIWIX && COMPOSE_SERVICES+=" kiwix" # shellcheck disable=SC2086 docker compose pull --quiet $COMPOSE_SERVICES # shellcheck disable=SC2086 docker compose up -d $COMPOSE_SERVICES ok "Stack started" # ── Pull Ollama models (answer was captured upfront) ───────────────────────── if $INSTALL_AI && $PULL_MODELS; then section "Pulling Ollama Models" info "Waiting for Ollama to be ready..." until docker exec ollama ollama list &>/dev/null; do sleep 3; done ok "Ollama ready." info "Pulling embed model (required for RAG)..." docker exec ollama ollama pull "$EMBED_MODEL" info "Pulling fast chat model..." docker exec ollama ollama pull "$FAST_MODEL" info "Pulling smart chat model..." docker exec ollama ollama pull "$CHAT_MODEL" info "Pulling code model..." docker exec ollama ollama pull "$CODE_MODEL" if [[ -n "$REASON_MODEL" ]]; then info "Pulling reasoning model ($REASON_MODEL)..." docker exec ollama ollama pull "$REASON_MODEL" fi ok "All models pulled." echo "" docker exec ollama ollama list elif $INSTALL_AI; then info "Skipping model pull — run later: bash $BASE/pull-models.sh" fi # ── Start ZIM downloads (answer was captured upfront) ──────────────────────── if $INSTALL_KIWIX && [[ "$ZIM_CHOICE" != "3" ]]; then section "Starting ZIM Downloads" LOG="$BASE/logs/kiwix-download.log" mkdir -p "$(dirname "$LOG")" info "Checking mirrors for latest ZIM filenames..." MIRROR="https://ftp.fau.de/kiwix/zim" MIRROR2="https://download.kiwix.org/zim" latest_zim() { local base_url="$1" pattern="$2" curl -s "$base_url/" | grep -oP "${pattern}_[0-9-]+\.zim" | sort -u | tail -1 } dl_zim() { local cat="$1" file="$2" desc="$3" mirror="${4:-$MIRROR}" local dest="$KIWIX_DIR/$file" if [[ -z "$file" ]]; then warn "Could not find $desc — skipping"; return; fi if [[ -f "$dest" ]]; then ok "$desc already downloaded"; return; fi info "Starting: $desc" nohup wget -c "$mirror/$cat/$file" -O "$dest" >> "$LOG" 2>&1 & echo $! >> "$KIWIX_DIR/.download_pids" ok "Download started (PID $!) — $desc" } WIKI=$(latest_zim "$MIRROR/wikipedia" "wikipedia_en_all_nopic") SO=$(latest_zim "$MIRROR/stack_exchange" "stackoverflow.com_en_all") ARCH=$(latest_zim "$MIRROR/other" "archlinux_en_all_maxi") WIKT=$(latest_zim "$MIRROR/wiktionary" "wiktionary_en_all_nopic") WIKB=$(latest_zim "$MIRROR/wikibooks" "wikibooks_en_all_nopic") WIKS=$(latest_zim "$MIRROR/wikisource" "wikisource_en_all_nopic") WIKV=$(latest_zim "$MIRROR/wikivoyage" "wikivoyage_en_all_nopic") WIKUNI=$(latest_zim "$MIRROR/wikiversity" "wikiversity_en_all_nopic") WIKNEWS=$(latest_zim "$MIRROR/wikinews" "wikinews_en_all_nopic") WIKQ=$(latest_zim "$MIRROR/wikiquote" "wikiquote_en_all_nopic") VIKIDIA=$(latest_zim "$MIRROR/vikidia" "vikidia_en_all_nopic") TED=$(latest_zim "$MIRROR/ted" "ted_mul_youth") PHET=$(latest_zim "$MIRROR/phet" "phet_en_all") DEVDOCS=$(latest_zim "$MIRROR/devdocs" "devdocs_en_zig") FCC=$(latest_zim "$MIRROR/freecodecamp" "freecodecamp_en_all") IFIX=$(latest_zim "$MIRROR/ifixit" "ifixit_en_all") LIBRE=$(latest_zim "$MIRROR/libretexts" "libretexts.org_en_workforce") GUT=$(latest_zim "$MIRROR2/gutenberg" "gutenberg_en_all") if [[ "$ZIM_CHOICE" == "2" ]]; then # Let user select which ZIMs echo "" echo " Select ZIMs to download (enter numbers separated by spaces):" echo " 1) Wikipedia ~46GB ${WIKI:-NOT FOUND}" echo " 2) Stack Overflow ~3GB ${SO:-NOT FOUND}" echo " 3) Arch Linux Wiki ~30MB ${ARCH:-NOT FOUND}" echo " 4) Wiktionary ~2GB ${WIKT:-NOT FOUND}" echo " 5) Wikibooks ~500MB ${WIKB:-NOT FOUND}" echo " 6) Wikisource ~4GB ${WIKS:-NOT FOUND}" echo " 7) Wikivoyage ~200MB ${WIKV:-NOT FOUND}" echo " 8) Wikiversity ~500MB ${WIKUNI:-NOT FOUND}" echo " 9) WikiNews ~300MB ${WIKNEWS:-NOT FOUND}" echo " 10) Wikiquote ~300MB ${WIKQ:-NOT FOUND}" echo " 11) Vikidia (kids K-8) ~66MB ${VIKIDIA:-NOT FOUND}" echo " 12) TED Talks ~5GB ${TED:-NOT FOUND}" echo " 13) PhET Simulations ~500MB ${PHET:-NOT FOUND}" echo " 14) DevDocs ~1GB ${DEVDOCS:-NOT FOUND}" echo " 15) FreeCodeCamp ~small ${FCC:-NOT FOUND}" echo " 16) iFixit ~2GB ${IFIX:-NOT FOUND}" echo " 17) LibreTexts ~varies ${LIBRE:-NOT FOUND}" echo " 18) Project Gutenberg ~60GB ${GUT:-NOT FOUND}" echo "" read -rp " Your choices (e.g. 1 2 3): " ZIM_PICKS rm -f "$KIWIX_DIR/.download_pids" for n in $ZIM_PICKS; do case "$n" in 1) dl_zim "wikipedia" "$WIKI" "Wikipedia" ;; 2) dl_zim "stack_exchange" "$SO" "Stack Overflow" ;; 3) dl_zim "other" "$ARCH" "Arch Linux Wiki" ;; 4) dl_zim "wiktionary" "$WIKT" "Wiktionary" ;; 5) dl_zim "wikibooks" "$WIKB" "Wikibooks" ;; 6) dl_zim "wikisource" "$WIKS" "Wikisource" ;; 7) dl_zim "wikivoyage" "$WIKV" "Wikivoyage" ;; 8) dl_zim "wikiversity" "$WIKUNI" "Wikiversity" ;; 9) dl_zim "wikinews" "$WIKNEWS" "WikiNews" ;; 10) dl_zim "wikiquote" "$WIKQ" "Wikiquote" ;; 11) dl_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" ;; 12) dl_zim "ted" "$TED" "TED Talks" ;; 13) dl_zim "phet" "$PHET" "PhET Simulations" ;; 14) dl_zim "devdocs" "$DEVDOCS" "DevDocs" ;; 15) dl_zim "freecodecamp" "$FCC" "FreeCodeCamp" ;; 16) dl_zim "ifixit" "$IFIX" "iFixit" ;; 17) dl_zim "libretexts" "$LIBRE" "LibreTexts" ;; 18) dl_zim "gutenberg" "$GUT" "Project Gutenberg" "$MIRROR2" ;; esac done else # Download all rm -f "$KIWIX_DIR/.download_pids" dl_zim "wikipedia" "$WIKI" "Wikipedia" dl_zim "stack_exchange" "$SO" "Stack Overflow" dl_zim "other" "$ARCH" "Arch Linux Wiki" dl_zim "wiktionary" "$WIKT" "Wiktionary" dl_zim "wikibooks" "$WIKB" "Wikibooks" dl_zim "wikisource" "$WIKS" "Wikisource" dl_zim "wikivoyage" "$WIKV" "Wikivoyage" dl_zim "wikiversity" "$WIKUNI" "Wikiversity" dl_zim "wikinews" "$WIKNEWS" "WikiNews" dl_zim "wikiquote" "$WIKQ" "Wikiquote" dl_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" dl_zim "ted" "$TED" "TED Talks" dl_zim "phet" "$PHET" "PhET Simulations" dl_zim "devdocs" "$DEVDOCS" "DevDocs" dl_zim "freecodecamp" "$FCC" "FreeCodeCamp" dl_zim "ifixit" "$IFIX" "iFixit" dl_zim "libretexts" "$LIBRE" "LibreTexts" dl_zim "gutenberg" "$GUT" "Project Gutenberg" "$MIRROR2" fi ok "ZIM downloads running in background — monitor: tail -f $LOG" fi # ============================================================================= echo "" echo -e "${GREEN}${BOLD}━━━ Done! ━━━${NC}" echo "" if $INSTALL_AI; then echo -e " ${CYAN}Open WebUI${NC} → http://$LOCAL_IP:3000" echo -e " ${CYAN}InvokeAI${NC} → http://$LOCAL_IP:9090" echo -e " ${CYAN}SearXNG${NC} → http://$LOCAL_IP:8888" echo -e " ${CYAN}Gitea${NC} → http://$LOCAL_IP:3001" echo -e " ${CYAN}RAG Health${NC} → http://$LOCAL_IP:8001/health" echo -e " ${CYAN}MCP SSE${NC} → http://$LOCAL_IP:8002/sse" echo -e " ${CYAN}Portainer${NC} → https://$LOCAL_IP:9443" fi if $INSTALL_KIWIX; then echo -e " ${CYAN}Kiwix${NC} → http://$LOCAL_IP:8181" fi echo "" if $INSTALL_AI; then echo -e " ${YELLOW}Add MCP to Claude Code:${NC}" echo " claude mcp add local http://$LOCAL_IP:8002/sse" echo "" echo -e " ${YELLOW}Add API tokens to:${NC} $BASE/.env" echo -e " ${YELLOW}Drop PDFs into:${NC} $BASE/papers/" echo -e " ${YELLOW}Your workspace:${NC} $BASE/workspace/" fi if $INSTALL_KIWIX && [[ "$ZIM_CHOICE" == "3" ]]; then echo -e " ${YELLOW}ZIM downloads:${NC} ./kiwix_download.sh (not started)" elif $INSTALL_KIWIX; then echo -e " ${YELLOW}ZIM progress:${NC} tail -f $BASE/logs/kiwix-download.log" echo -e " ${YELLOW}ZIM location:${NC} $KIWIX_DIR/" fi echo "" $IS_UPDATE && echo -e " ${GREEN}Update complete.${NC}" \ || echo -e " ${GREEN}Fresh install complete.${NC}" echo ""