#!/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 # 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 ──────────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BASE="$SCRIPT_DIR" 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: Top-level — what to run ─────────────────────────────────────────────── INSTALL_POSTINSTALL=false INSTALL_AI=true # Per-service flags (all default ON — toggled off in Q1a) SVC_WEBUI=true; SVC_RAG=true; SVC_MCP=true SVC_GITEA=true; SVC_INVOKEAI=true; SVC_PORTAINER=true; SVC_KIWIX=true POSTINSTALL_SCRIPT="$SCRIPT_DIR/ubuntu-post-install.sh" HAS_POSTINSTALL=false [[ -f "$POSTINSTALL_SCRIPT" ]] && HAS_POSTINSTALL=true 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 — select services on next screen" ON ) SELECTED=$(whiptail --title "Local AI Setup" \ --checklist "SPACE = toggle TAB = move ENTER = confirm" \ 12 68 "${#WHIP_ARGS[@]}" "${WHIP_ARGS[@]}" 3>&1 1>&2 2>&3) || die "Cancelled." INSTALL_AI=false [[ "$SELECTED" == *"postinstall"* ]] && INSTALL_POSTINSTALL=true [[ "$SELECTED" == *"aistack"* ]] && INSTALL_AI=true else echo "" echo -e " ${BOLD}[1] Select what to run${NC}" echo " (Type a number to toggle, Enter to confirm)" SEL_POST=false; SEL_AI=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 — select services on next screen\n" "$($SEL_AI && 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 ;; "") break ;; esac done INSTALL_POSTINSTALL=$SEL_POST INSTALL_AI=$SEL_AI fi $INSTALL_AI || $INSTALL_POSTINSTALL \ || die "Nothing selected — run again and select at least one option." # ── Q1a: AI stack service selection ─────────────────────────────────────────── if $INSTALL_AI; then # detect which containers already exist (mark them ON) existing_svc() { docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^$1$" && echo ON || echo OFF } if command -v whiptail &>/dev/null; then SELECTED_SVCS=$(whiptail --title "AI Stack — Select Services" \ --checklist "Ollama always installed (core). SPACE = toggle ENTER = confirm" \ 20 72 9 \ "open-webui" "Open WebUI — Chat interface (like ChatGPT, uses Ollama)" "$(existing_svc open-webui)" \ "rag" "RAG + ChromaDB— Code & document search (needed by MCP)" "$(existing_svc rag-server)" \ "mcp" "MCP Server — Claude Code tools (bash, files, git, search)" "$(existing_svc mcp-server)" \ "gitea" "Gitea — Self-hosted Git server" "$(existing_svc gitea)" \ "invokeai" "InvokeAI — Image generation (Stable Diffusion)" "$(existing_svc invokeai)" \ "portainer" "Portainer — Docker management web UI" "$(existing_svc portainer)" \ "kiwix" "Kiwix — Offline Wikipedia, Stack Overflow, Arch Wiki" "$(existing_svc kiwix)" \ 3>&1 1>&2 2>&3) || die "Cancelled." SVC_WEBUI=false; [[ "$SELECTED_SVCS" == *'"open-webui"'* ]] && SVC_WEBUI=true SVC_RAG=false; [[ "$SELECTED_SVCS" == *'"rag"'* ]] && SVC_RAG=true SVC_MCP=false; [[ "$SELECTED_SVCS" == *'"mcp"'* ]] && SVC_MCP=true SVC_GITEA=false; [[ "$SELECTED_SVCS" == *'"gitea"'* ]] && SVC_GITEA=true SVC_INVOKEAI=false; [[ "$SELECTED_SVCS" == *'"invokeai"'* ]] && SVC_INVOKEAI=true SVC_PORTAINER=false;[[ "$SELECTED_SVCS" == *'"portainer"'* ]] && SVC_PORTAINER=true SVC_KIWIX=false; [[ "$SELECTED_SVCS" == *'"kiwix"'* ]] && SVC_KIWIX=true else # Text toggle fallback echo "" echo -e " ${BOLD}[1a] AI Stack — select services to install${NC}" echo " Ollama always included (required core). Type number to toggle, Enter to confirm." _W=$SVC_WEBUI; _R=$SVC_RAG; _M=$SVC_MCP _G=$SVC_GITEA; _I=$SVC_INVOKEAI; _P=$SVC_PORTAINER; _K=$SVC_KIWIX while true; do echo "" printf " [%s] 1. Open WebUI — Chat interface\n" "$($_W && echo '*' || echo ' ')" printf " [%s] 2. RAG+ChromaDB — Code & document search\n" "$($_R && echo '*' || echo ' ')" printf " [%s] 3. MCP Server — Claude Code tools (needs RAG)\n" "$($_M && echo '*' || echo ' ')" printf " [%s] 4. Gitea — Self-hosted Git\n" "$($_G && echo '*' || echo ' ')" printf " [%s] 5. InvokeAI — Image generation\n" "$($_I && echo '*' || echo ' ')" printf " [%s] 6. Portainer — Docker management UI\n" "$($_P && echo '*' || echo ' ')" printf " [%s] 7. Kiwix — Offline Wikipedia, Stack Overflow\n" "$($_K && echo '*' || echo ' ')" echo "" read -rp " Toggle [number] or Enter to confirm: " T case "$T" in 1) $_W && _W=false || _W=true ;; 2) $_R && _R=false || _R=true ;; 3) $_M && _M=false || _M=true ;; 4) $_G && _G=false || _G=true ;; 5) $_I && _I=false || _I=true ;; 6) $_P && _P=false || _P=true ;; 7) $_K && _K=false || _K=true ;; "") break ;; esac done SVC_WEBUI=$_W; SVC_RAG=$_R; SVC_MCP=$_M SVC_GITEA=$_G; SVC_INVOKEAI=$_I; SVC_PORTAINER=$_P; SVC_KIWIX=$_K fi # Enforce dependencies: MCP needs RAG $SVC_MCP && ! $SVC_RAG && { warn "MCP Server requires RAG — enabling RAG+ChromaDB"; SVC_RAG=true; } fi # ── 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 "" mapfile -t _MPTS < <( 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 " 0) Docker volume (default — /var/lib/docker/volumes/)" for _i in "${!_MPTS[@]}"; do printf " %d) %s\n" "$((_i+1))" "${_MPTS[$_i]}" done echo "" read -rp " Choice [0]: " STORAGE_CHOICE STORAGE_CHOICE="${STORAGE_CHOICE:-0}" if [[ "$STORAGE_CHOICE" == "0" ]]; then OLLAMA_STORAGE="volume" elif [[ "$STORAGE_CHOICE" =~ ^[0-9]+$ ]] && (( STORAGE_CHOICE >= 1 && STORAGE_CHOICE <= ${#_MPTS[@]} )); then _MP=$(awk '{print $1}' <<< "${_MPTS[$(( STORAGE_CHOICE-1 ))]}") OLLAMA_HOST_PATH="$_MP/ollama-models" OLLAMA_STORAGE="bind" ok "Ollama models → $OLLAMA_HOST_PATH" else # Fallback: treat input as a literal path OLLAMA_HOST_PATH="${STORAGE_CHOICE%/}" [[ -z "$OLLAMA_HOST_PATH" ]] && die "No path entered." OLLAMA_STORAGE="bind" fi unset _MPTS _MP _i fi # ── Q3: Storage for Kiwix ZIMs ──────────────────────────────────────────────── KIWIX_DIR="$BASE/kiwix" # default if $SVC_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 "" mapfile -t _MPTS < <( 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 " 0) Default: $KIWIX_DIR" for _i in "${!_MPTS[@]}"; do printf " %d) %s\n" "$((_i+1))" "${_MPTS[$_i]}" done echo "" read -rp " Choice [0]: " KIWIX_CHOICE KIWIX_CHOICE="${KIWIX_CHOICE:-0}" if [[ "$KIWIX_CHOICE" != "0" ]] && [[ "$KIWIX_CHOICE" =~ ^[0-9]+$ ]] && (( KIWIX_CHOICE >= 1 && KIWIX_CHOICE <= ${#_MPTS[@]} )); then _MP=$(awk '{print $1}' <<< "${_MPTS[$(( KIWIX_CHOICE-1 ))]}") KIWIX_DIR="$_MP/kiwix" ok "Kiwix ZIMs → $KIWIX_DIR" elif [[ "$KIWIX_CHOICE" != "0" ]] && [[ -n "$KIWIX_CHOICE" ]]; then # Fallback: treat as a literal path KIWIX_DIR="${KIWIX_CHOICE%/}" fi unset _MPTS _MP _i _KIWIX_CHOICE # ── Q4: Download ZIMs now? ───────────────────────────────────────────────── # ON if any matching ZIM file already exists in KIWIX_DIR _zimon() { ls "$KIWIX_DIR/${1}"*.zim 2>/dev/null | grep -q . && echo ON || echo OFF; } ZIM_CHOICE="3" ZIM_PICKS="" if command -v whiptail &>/dev/null && [[ -t 0 ]]; then if _ZIM_MODE=$(whiptail --backtitle "Kiwix" \ --title "[4/6] Kiwix ZIM Downloads" \ --radiolist "Download offline Wikipedia, Stack Overflow, etc.? (runs in background)" \ 11 68 3 \ "select" "Choose individual ZIMs " ON \ "all" "Download everything (~130 GB) " OFF \ "skip" "Skip — run kiwix_download.sh later " OFF \ 3>&1 1>&2 2>&3 2>/dev/null); then _ZIM_MODE="${_ZIM_MODE//\"/}" case "$_ZIM_MODE" in all) ZIM_CHOICE="1" ;; select|*) ZIM_CHOICE="2" if _ZIM_SEL=$(whiptail --backtitle "Kiwix" \ --title "Select ZIMs to Download" \ --checklist "SPACE = toggle ENTER = confirm ESC = skip\nPre-checked = already downloaded" \ 24 72 16 \ "wikipedia" "$(printf '%-30s %7s' 'Wikipedia' '~46 GB')" "$(_zimon wikipedia_en_all_nopic)" \ "stackoverflow" "$(printf '%-30s %7s' 'Stack Overflow' '~3 GB')" "$(_zimon stackoverflow.com_en_all)" \ "archlinux" "$(printf '%-30s %7s' 'Arch Linux Wiki' '~30 MB')" "$(_zimon archlinux_en_all)" \ "wiktionary" "$(printf '%-30s %7s' 'Wiktionary' '~2 GB')" "$(_zimon wiktionary_en_all_nopic)" \ "wikibooks" "$(printf '%-30s %7s' 'Wikibooks' '~500 MB')" "$(_zimon wikibooks_en_all_nopic)" \ "wikisource" "$(printf '%-30s %7s' 'Wikisource' '~4 GB')" "$(_zimon wikisource_en_all_nopic)" \ "wikivoyage" "$(printf '%-30s %7s' 'Wikivoyage' '~200 MB')" "$(_zimon wikivoyage_en_all_nopic)" \ "wikiversity" "$(printf '%-30s %7s' 'Wikiversity' '~500 MB')" "$(_zimon wikiversity_en_all_nopic)" \ "wikinews" "$(printf '%-30s %7s' 'WikiNews' '~300 MB')" "$(_zimon wikinews_en_all_nopic)" \ "wikiquote" "$(printf '%-30s %7s' 'Wikiquote' '~300 MB')" "$(_zimon wikiquote_en_all_nopic)" \ "vikidia" "$(printf '%-30s %7s' 'Vikidia (kids K-8)' '~66 MB')" "$(_zimon vikidia_en_all_nopic)" \ "ted" "$(printf '%-30s %7s' 'TED Talks' '~5 GB')" "$(_zimon ted_mul_youth)" \ "phet" "$(printf '%-30s %7s' 'PhET Simulations' '~500 MB')" "$(_zimon phet_en_all)" \ "devdocs" "$(printf '%-30s %7s' 'DevDocs' '~1 GB')" "$(_zimon devdocs_en_zig)" \ "freecodecamp" "$(printf '%-30s %7s' 'FreeCodeCamp' 'small')" "$(_zimon freecodecamp_en_all)" \ "ifixit" "$(printf '%-30s %7s' 'iFixit' '~2 GB')" "$(_zimon ifixit_en_all)" \ "libretexts" "$(printf '%-30s %7s' 'LibreTexts' 'varies')" "$(_zimon libretexts.org_en)" \ "gutenberg" "$(printf '%-30s %7s' 'Project Gutenberg' '~60 GB')" "$(_zimon gutenberg_en_all)" \ 3>&1 1>&2 2>&3 2>/dev/null); then if [[ -n "$_ZIM_SEL" ]]; then ZIM_PICKS=$(tr -d '"' <<< "$_ZIM_SEL") else ZIM_CHOICE="3" # Enter with nothing checked → skip fi else ZIM_CHOICE="3" # ESC → skip fi ;; esac fi # ESC on radiolist → ZIM_CHOICE stays "3" else # Text fallback 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}" if [[ "$ZIM_CHOICE" == "2" ]]; then _zim_tag() { ls "$KIWIX_DIR/${1}"*.zim 2>/dev/null | grep -q . && echo " ${GREEN}[downloaded]${NC}" || echo "" } echo "" echo " Select ZIMs to download (space-separated numbers, e.g. 1 3 5):" printf " %2s) %-26s %7s %s\n" 1 "Wikipedia" "~46 GB" "$(_zim_tag wikipedia_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 2 "Stack Overflow" "~3 GB" "$(_zim_tag stackoverflow.com_en_all)" printf " %2s) %-26s %7s %s\n" 3 "Arch Linux Wiki" "~30 MB" "$(_zim_tag archlinux_en_all)" printf " %2s) %-26s %7s %s\n" 4 "Wiktionary" "~2 GB" "$(_zim_tag wiktionary_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 5 "Wikibooks" "~500 MB" "$(_zim_tag wikibooks_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 6 "Wikisource" "~4 GB" "$(_zim_tag wikisource_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 7 "Wikivoyage" "~200 MB" "$(_zim_tag wikivoyage_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 8 "Wikiversity" "~500 MB" "$(_zim_tag wikiversity_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 9 "WikiNews" "~300 MB" "$(_zim_tag wikinews_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 10 "Wikiquote" "~300 MB" "$(_zim_tag wikiquote_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 11 "Vikidia (kids K-8)" "~66 MB" "$(_zim_tag vikidia_en_all_nopic)" printf " %2s) %-26s %7s %s\n" 12 "TED Talks" "~5 GB" "$(_zim_tag ted_mul_youth)" printf " %2s) %-26s %7s %s\n" 13 "PhET Simulations" "~500 MB" "$(_zim_tag phet_en_all)" printf " %2s) %-26s %7s %s\n" 14 "DevDocs" "~1 GB" "$(_zim_tag devdocs_en_zig)" printf " %2s) %-26s %7s %s\n" 15 "FreeCodeCamp" "small" "$(_zim_tag freecodecamp_en_all)" printf " %2s) %-26s %7s %s\n" 16 "iFixit" "~2 GB" "$(_zim_tag ifixit_en_all)" printf " %2s) %-26s %7s %s\n" 17 "LibreTexts" "varies" "$(_zim_tag libretexts.org_en)" printf " %2s) %-26s %7s %s\n" 18 "Project Gutenberg" "~60 GB" "$(_zim_tag gutenberg_en_all)" echo "" read -rp " Your choices (e.g. 1 2 3): " _ZIM_NUMS for _n in $_ZIM_NUMS; do case "$_n" in 1) ZIM_PICKS+="${ZIM_PICKS:+ }wikipedia" ;; 2) ZIM_PICKS+="${ZIM_PICKS:+ }stackoverflow" ;; 3) ZIM_PICKS+="${ZIM_PICKS:+ }archlinux" ;; 4) ZIM_PICKS+="${ZIM_PICKS:+ }wiktionary" ;; 5) ZIM_PICKS+="${ZIM_PICKS:+ }wikibooks" ;; 6) ZIM_PICKS+="${ZIM_PICKS:+ }wikisource" ;; 7) ZIM_PICKS+="${ZIM_PICKS:+ }wikivoyage" ;; 8) ZIM_PICKS+="${ZIM_PICKS:+ }wikiversity" ;; 9) ZIM_PICKS+="${ZIM_PICKS:+ }wikinews" ;; 10) ZIM_PICKS+="${ZIM_PICKS:+ }wikiquote" ;; 11) ZIM_PICKS+="${ZIM_PICKS:+ }vikidia" ;; 12) ZIM_PICKS+="${ZIM_PICKS:+ }ted" ;; 13) ZIM_PICKS+="${ZIM_PICKS:+ }phet" ;; 14) ZIM_PICKS+="${ZIM_PICKS:+ }devdocs" ;; 15) ZIM_PICKS+="${ZIM_PICKS:+ }freecodecamp" ;; 16) ZIM_PICKS+="${ZIM_PICKS:+ }ifixit" ;; 17) ZIM_PICKS+="${ZIM_PICKS:+ }libretexts" ;; 18) ZIM_PICKS+="${ZIM_PICKS:+ }gutenberg" ;; esac done unset -f _zim_tag fi fi unset -f _zimon 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 # 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 "" echo " Size tier — estimated speed on your ${VRAM_GB}GB GPU:" echo " (No hard limits — Ollama uses all available VRAM automatically)" echo "" printf " %-4s %-8s %-42s %s\n" "#" "Tier" "Models" "Speed on your system" printf " %-4s %-8s %-42s %s\n" "----" "--------" "------------------------------------------" "--------------------" declare -a _TIER_NAMES case "$MODEL_PREF" in 1) # Western _TIER_NAMES=(7B 14B 22B 70B) printf " %-4s %-8s %-42s %s\n" "1)" "7B" "mistral:7b + codellama:7b" "$(speed_label 4)" printf " %-4s %-8s %-42s %s\n" "2)" "14B" "phi4:14b + starcoder2:15b" "$(speed_label 9)" printf " %-4s %-8s %-42s %s\n" "3)" "22B" "phi4:14b + codestral:22b" "$(speed_label 13)" printf " %-4s %-8s %-42s %s\n" "4)" "70B" "llama3.3:70b + codestral:22b" "$(speed_label 41)" ;; 2) # Performance _TIER_NAMES=(7B 14B 32B 72B) printf " %-4s %-8s %-42s %s\n" "1)" "7B" "qwen2.5:7b + qwen2.5-coder:7b" "$(speed_label 4)" printf " %-4s %-8s %-42s %s\n" "2)" "14B" "qwen2.5:14b + qwen2.5-coder:14b" "$(speed_label 9)" printf " %-4s %-8s %-42s %s\n" "3)" "32B" "qwen2.5:14b + qwen2.5-coder:32b" "$(speed_label 19)" printf " %-4s %-8s %-42s %s\n" "4)" "72B" "qwen2.5:72b + qwen2.5-coder:32b" "$(speed_label 41)" ;; 3) # Mixed _TIER_NAMES=(7B 14B 32B 70B) printf " %-4s %-8s %-42s %s\n" "1)" "7B" "mistral:7b + qwen2.5-coder:7b" "$(speed_label 4)" printf " %-4s %-8s %-42s %s\n" "2)" "14B" "phi4:14b + qwen2.5-coder:14b" "$(speed_label 9)" printf " %-4s %-8s %-42s %s\n" "3)" "32B" "phi4:14b + qwen2.5-coder:32b" "$(speed_label 19)" printf " %-4s %-8s %-42s %s\n" "4)" "70B" "llama3.3:70b + qwen2.5-coder:32b" "$(speed_label 41)" ;; esac # Figure out recommended number from REC_TIER REC_NUM=2 for i in "${!_TIER_NAMES[@]}"; do [[ "${_TIER_NAMES[$i]}" == "$REC_TIER" ]] && REC_NUM=$((i+1)) done 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_NUM]: " _TIER_INPUT _TIER_INPUT="${_TIER_INPUT:-$REC_NUM}" # Accept either a number (1-4) or the tier name directly if [[ "$_TIER_INPUT" =~ ^[1-4]$ ]]; then TIER_PICK="${_TIER_NAMES[$((_TIER_INPUT-1))]}" else TIER_PICK="$_TIER_INPUT" fi unset _TIER_NAMES _TIER_INPUT REC_NUM 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)" [[ "${#SSH_IMPORT_IDS[@]}" -gt 0 ]] && echo " ✓ SSH keys → ${SSH_IMPORT_IDS[*]}" if $INSTALL_AI; then _svcs="Ollama" $SVC_WEBUI && _svcs+=" · Open WebUI" $SVC_RAG && _svcs+=" · RAG+ChromaDB" $SVC_MCP && _svcs+=" · MCP" $SVC_GITEA && _svcs+=" · Gitea" $SVC_INVOKEAI && _svcs+=" · InvokeAI" $SVC_PORTAINER && _svcs+=" · Portainer" $SVC_KIWIX && _svcs+=" · Kiwix" echo " ✓ AI stack → $_svcs" fi if $INSTALL_AI; then if [[ "$OLLAMA_STORAGE" == "bind" ]]; then echo " ✓ Ollama models → $OLLAMA_HOST_PATH" else echo " ✓ Ollama models → Docker volume (default)" fi fi $SVC_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 (~130 GB)" if [[ "$ZIM_CHOICE" == "2" ]]; then _nzim=$(wc -w <<< "$ZIM_PICKS") echo " ✓ Download ${_nzim} ZIM(s): $ZIM_PICKS" fi 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 if [[ -e "$dest" ]] && [[ ! -w "$dest" ]]; then printf '%s\n' "$content" | sudo tee "$dest" > /dev/null else printf '%s\n' "$content" > "$dest" fi 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 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 # ============================================================================= 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 # Open WebUI — set to your public FQDN when behind a reverse proxy (Caddy etc.) # Without this, sessions/cookies break when accessed via domain name. # Example: WEBUI_URL=https://webui.yourdomain.com WEBUI_URL= # 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 .env in this folder to add API tokens. # GPU: OLLAMA_NUM_GPU=999 means "use all available VRAM" — auto-adapts to any GPU. # # ── Common commands (run from this folder) ───────────────────────────────────── # Start everything: docker compose up -d # Stop everything: docker compose down # Restart one service: docker compose restart # Stop one service: docker compose stop # Start one service: docker compose up -d # Follow all logs: docker compose logs -f # Follow one service logs: docker compose logs -f # Pull latest images: docker compose pull && docker compose up -d # Show status: docker compose ps # # Services: ollama open-webui chromadb rag-server mcp-server # kiwix gitea invokeai portainer # ─────────────────────────────────────────────────────────────────────────────── 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_TOOL_SERVERS=true - WEBUI_AUTH=true - WEBUI_URL=${WEBUI_URL:-} 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 # ── Kiwix — Offline Wikipedia/docs ───────────────────────────────────────── kiwix: image: ghcr.io/kiwix/kiwix-serve:latest container_name: kiwix restart: unless-stopped ports: - "0.0.0.0:8181:80" volumes: - $KIWIX_DIR:/data entrypoint: ["sh", "-c"] command: ["ls /data/*.zim >/dev/null 2>&1 && exec kiwix-serve /data/*.zim || { echo 'No ZIM files in /data yet - sleeping. Add .zim files and restart kiwix.'; exec sleep infinity; }"] # ── 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" \ "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" # ============================================================================= # Build URL list for start.sh (only include selected services) _START_URLS="" $SVC_WEBUI && _START_URLS+=$'echo " Open WebUI → http://'"$LOCAL_IP"$':3000"\n' $SVC_INVOKEAI && _START_URLS+=$'echo " InvokeAI → http://'"$LOCAL_IP"$':9090"\n' $SVC_GITEA && _START_URLS+=$'echo " Gitea → http://'"$LOCAL_IP"$':3001"\n' $SVC_RAG && _START_URLS+=$'echo " RAG Health → http://'"$LOCAL_IP"$':8001/health"\n' $SVC_MCP && _START_URLS+=$'echo " MCP SSE → http://'"$LOCAL_IP"$':8002/sse"\n' $SVC_PORTAINER && _START_URLS+=$'echo " Portainer → https://'"$LOCAL_IP"$':9443"\n' $SVC_KIWIX && _START_URLS+=$'echo " Kiwix → http://'"$LOCAL_IP"$':8181"\n' _START_MCP="" $SVC_MCP && _START_MCP=$'echo ""\necho " Claude Code MCP:"\necho " claude mcp add local http://'"$LOCAL_IP"$':8002/sse"\n' _START_PDFS="" $SVC_RAG && _START_PDFS=$'echo " Drop PDFs → '"$BASE"$'/papers/"\n' _START_IMGS="" $SVC_INVOKEAI && _START_IMGS=$'echo " Images out → '"$BASE"$'/invokeai-outputs/"\n' cat > "$BASE/start.sh" << STARTSH #!/bin/bash cd "$BASE" echo "Pulling latest images..." docker compose pull --quiet docker compose up -d echo "" $_START_URLS echo " Workspace → $BASE/workspace/" $_START_PDFS$_START_IMGS$_START_MCP 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 $LOCAL_IP:3000 { header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } } invokeai.yourdomain.com { reverse_proxy $LOCAL_IP:9090 } git.yourdomain.com { reverse_proxy $LOCAL_IP:3001 } kiwix.yourdomain.com { reverse_proxy $LOCAL_IP:8181 } rag.yourdomain.com { reverse_proxy $LOCAL_IP:8001 } mcp.yourdomain.com { reverse_proxy $LOCAL_IP:8002 } portainer.yourdomain.com { reverse_proxy https://$LOCAL_IP: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="" if $INSTALL_AI; then COMPOSE_SERVICES="ollama" $SVC_WEBUI && COMPOSE_SERVICES+=" open-webui" $SVC_RAG && COMPOSE_SERVICES+=" chromadb rag-server" $SVC_MCP && COMPOSE_SERVICES+=" mcp-server" $SVC_GITEA && COMPOSE_SERVICES+=" gitea" $SVC_INVOKEAI && COMPOSE_SERVICES+=" invokeai" $SVC_PORTAINER && COMPOSE_SERVICES+=" portainer" $SVC_KIWIX && COMPOSE_SERVICES+=" kiwix" fi # 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 $SVC_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 # ZIM_PICKS contains space-separated ZIM names selected during the wizard rm -f "$KIWIX_DIR/.download_pids" for _zn in $ZIM_PICKS; do case "$_zn" in wikipedia) dl_zim "wikipedia" "$WIKI" "Wikipedia" ;; stackoverflow) dl_zim "stack_exchange" "$SO" "Stack Overflow" ;; archlinux) dl_zim "other" "$ARCH" "Arch Linux Wiki" ;; wiktionary) dl_zim "wiktionary" "$WIKT" "Wiktionary" ;; wikibooks) dl_zim "wikibooks" "$WIKB" "Wikibooks" ;; wikisource) dl_zim "wikisource" "$WIKS" "Wikisource" ;; wikivoyage) dl_zim "wikivoyage" "$WIKV" "Wikivoyage" ;; wikiversity) dl_zim "wikiversity" "$WIKUNI" "Wikiversity" ;; wikinews) dl_zim "wikinews" "$WIKNEWS" "WikiNews" ;; wikiquote) dl_zim "wikiquote" "$WIKQ" "Wikiquote" ;; vikidia) dl_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" ;; ted) dl_zim "ted" "$TED" "TED Talks" ;; phet) dl_zim "phet" "$PHET" "PhET Simulations" ;; devdocs) dl_zim "devdocs" "$DEVDOCS" "DevDocs" ;; freecodecamp) dl_zim "freecodecamp" "$FCC" "FreeCodeCamp" ;; ifixit) dl_zim "ifixit" "$IFIX" "iFixit" ;; libretexts) dl_zim "libretexts" "$LIBRE" "LibreTexts" ;; gutenberg) 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 $SVC_WEBUI && echo -e " ${CYAN}Open WebUI${NC} → http://$LOCAL_IP:3000" $SVC_INVOKEAI && echo -e " ${CYAN}InvokeAI${NC} → http://$LOCAL_IP:9090" $SVC_GITEA && echo -e " ${CYAN}Gitea${NC} → http://$LOCAL_IP:3001" $SVC_RAG && echo -e " ${CYAN}RAG Health${NC} → http://$LOCAL_IP:8001/health" $SVC_MCP && echo -e " ${CYAN}MCP SSE${NC} → http://$LOCAL_IP:8002/sse" $SVC_PORTAINER && echo -e " ${CYAN}Portainer${NC} → https://$LOCAL_IP:9443" $SVC_KIWIX && echo -e " ${CYAN}Kiwix${NC} → http://$LOCAL_IP:8181" fi echo "" if $INSTALL_AI; then $SVC_MCP && $SVC_WEBUI && { echo -e " ${YELLOW}Add MCP to Open WebUI (one-time):${NC}" echo " Open WebUI → Admin → Settings → Tools → +" echo " URL: http://$LOCAL_IP:8002/sse" echo "" } $SVC_MCP && { 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" $SVC_RAG && echo -e " ${YELLOW}Drop PDFs into:${NC} $BASE/papers/" echo -e " ${YELLOW}Your workspace:${NC} $BASE/workspace/" fi if $SVC_KIWIX && [[ "$ZIM_CHOICE" == "3" ]]; then echo -e " ${YELLOW}ZIM downloads:${NC} ./kiwix_download.sh (not started)" elif $SVC_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 ""