Files
local-ai/laptop_full_setup.sh
Claude 7ffd012a3a Wire GPU-aware image gen into setup: auto-install, precision, ComfyUI
Previously display-only: IMG_TIER/IMG_MODELS were detected but never
used. Now they drive actual behavior:

- Both setup scripts call setup-image-models.sh --auto after model
  pulls, installing the right SD/SDXL/Flux model for the detected GPU
- INVOKEAI_PRECISION is now GPU-aware: bfloat16 for Ampere+ (compute
  8.0+), float16 for Pascal+, auto fallback for older cards. Was
  hardcoded to float16.
- local-ai-setup.sh now includes ComfyUI service + Open WebUI
  integration (ENABLE_IMAGE_GENERATION=true, COMFYUI_BASE_URL) — was
  completely missing, only laptop_full_setup.sh had it
- Added ComfyUI port 8188 to UFW firewall rules in local-ai-setup.sh
- Added comfyui-data/comfyui-output directories to mkdir loop
- Updated start.sh and final output to show ComfyUI URL

https://claude.ai/code/session_01PtYTPherSJaxDEVPgF6Nxu
2026-03-22 21:33:25 +00:00

1690 lines
80 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 · ComfyUI · 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="qwen3.5:9b"
CODE_MODEL="qwen3.5:9b"
FAST_MODEL="qwen3.5:4b"
# ── 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_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l || echo "0")
GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo "None")
TOTAL_VRAM=$((VRAM_GB * GPU_COUNT))
# Ollama optimization flags (stacked — see docs/gpu-setup-research.md)
OLLAMA_KV_CACHE="q8_0" # halves KV cache VRAM (q4_0 for aggressive)
OLLAMA_FLASH="1" # flash attention: less VRAM, no quality loss
if [[ "$TOTAL_VRAM" -ge 40 ]]; then
CHAT_MODEL="qwen3.5:27b"; CODE_MODEL="qwen3.5:27b"
CTX=131072; GPU_TIER="${TOTAL_VRAM}GB VRAM — 27B dense, 128K context"
elif [[ "$TOTAL_VRAM" -ge 28 ]]; then
CHAT_MODEL="qwen3.5-35b-a3b"; CODE_MODEL="qwen3.5-35b-a3b"
CTX=131072; GPU_TIER="${TOTAL_VRAM}GB VRAM — 35B MoE, 128K context"
elif [[ "$TOTAL_VRAM" -ge 14 ]]; then
CHAT_MODEL="qwen3.5-35b-a3b"; CODE_MODEL="qwen3.5-35b-a3b"
CTX=65536; GPU_TIER="${TOTAL_VRAM}GB VRAM — 35B MoE + KV quant, 64K context"
elif [[ "$TOTAL_VRAM" -ge 8 ]]; then
CHAT_MODEL="qwen3.5:9b"; CODE_MODEL="qwen3.5:9b"
CTX=32768; GPU_TIER="${TOTAL_VRAM}GB VRAM — 9B dense, 32K context"
elif [[ "$TOTAL_VRAM" -ge 6 ]]; then
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
CTX=32768; GPU_TIER="${TOTAL_VRAM}GB VRAM — 4B + KV quant, 32K context (3.5GB free for cache)"
elif [[ "$TOTAL_VRAM" -ge 4 ]]; then
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
CTX=16384; GPU_TIER="${TOTAL_VRAM}GB VRAM — 4B models, 16K context"
elif [[ "$TOTAL_VRAM" -gt 0 ]]; then
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
CTX=8192; GPU_TIER="${TOTAL_VRAM}GB VRAM — 4B models"
else
CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"
CTX=4096; OLLAMA_KV_CACHE="q4_0"; GPU_TIER="CPU only — 4B models (slow)"
fi
# ── Image generation model tiers (VRAM-aware) ────────────────────────────────
# Image gen shares GPU with Ollama — Ollama unloads after KEEP_ALIVE timeout,
# so image gen gets full VRAM when Ollama is idle.
if [[ "$TOTAL_VRAM" -ge 24 ]]; then
IMG_MODELS="SD 1.5, SDXL, SDXL Turbo, Flux.1-dev, Flux.1-schnell"
IMG_TIER="all models including Flux"
IMG_DEFAULT="SDXL"
elif [[ "$TOTAL_VRAM" -ge 12 ]]; then
IMG_MODELS="SD 1.5, SDXL, SDXL Turbo, Flux.1-schnell (tight)"
IMG_TIER="SDXL + Flux-schnell"
IMG_DEFAULT="SDXL"
elif [[ "$TOTAL_VRAM" -ge 8 ]]; then
IMG_MODELS="SD 1.5, SDXL (tight at 512px), SDXL Turbo"
IMG_TIER="SD 1.5 comfortable, SDXL possible"
IMG_DEFAULT="SD 1.5"
elif [[ "$TOTAL_VRAM" -ge 4 ]]; then
IMG_MODELS="SD 1.5 (float16)"
IMG_TIER="SD 1.5 only"
IMG_DEFAULT="SD 1.5"
else
IMG_MODELS="none (CPU generation extremely slow)"
IMG_TIER="CPU only — not recommended"
IMG_DEFAULT=""
fi
# ── InvokeAI precision (GPU-aware) ───────────────────────────────────────────
# Ampere+ (compute 8.0+) supports bfloat16 natively for better precision.
# All modern NVIDIA GPUs support float16. Fall back to auto if unsure.
GPU_COMPUTE=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null \
| head -1 | tr -d '.' || echo "0")
if [[ "$GPU_COMPUTE" -ge 80 ]]; then
INVOKEAI_PRECISION="bfloat16" # Ampere+ (RTX 30xx/40xx/50xx, A-series, H100)
elif [[ "$GPU_COMPUTE" -ge 60 ]]; then
INVOKEAI_PRECISION="float16" # Pascal+ (GTX 10xx, RTX 20xx, Tesla P40/V100)
else
INVOKEAI_PRECISION="auto" # Let InvokeAI decide
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)"
info "Image : $IMG_TIER"
$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_COMFYUI=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" \
22 78 10 \
"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)" \
"comfyui" "ComfyUI — Node-based image gen (integrates w/ Open WebUI)" "$(existing_svc comfyui)" \
"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_COMFYUI=false; [[ "$SELECTED_SVCS" == *'"comfyui"'* ]] && SVC_COMFYUI=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; _C=$SVC_COMFYUI; _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. ComfyUI — Node-based image gen (OWUI integration)\n" "$($_C && echo '*' || echo ' ')"
printf " [%s] 7. Portainer — Docker management UI\n" "$($_P && echo '*' || echo ' ')"
printf " [%s] 8. 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) $_C && _C=false || _C=true ;;
7) $_P && _P=false || _P=true ;;
8) $_K && _K=false || _K=true ;;
"") break ;;
esac
done
SVC_WEBUI=$_W; SVC_RAG=$_R; SVC_MCP=$_M
SVC_GITEA=$_G; SVC_INVOKEAI=$_I; SVC_COMFYUI=$_C; 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
if command -v whiptail &>/dev/null; then
SSH_INPUT=$(whiptail --title "SSH Key Import" \
--inputbox "Import SSH public keys for passwordless SSH into this machine.\n\nExamples: gh:yourusername lp:yourlaunchpadid\nMultiple: gh:alice lp:alice\n\nLeave blank to skip." \
14 68 "" 3>&1 1>&2 2>&3) || SSH_INPUT=""
else
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
fi
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
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
)
if command -v whiptail &>/dev/null; then
WHIP_STORAGE=("volume" "Docker volume (default — /var/lib/docker/volumes/)" ON)
for _i in "${!_MPTS[@]}"; do
_mp_path=$(awk '{print $1}' <<< "${_MPTS[$_i]}")
_mp_avail=$(awk '{print $2}' <<< "${_MPTS[$_i]}")
WHIP_STORAGE+=("$_mp_path" "${_mp_path} (${_mp_avail} free)" OFF)
done
STORAGE_CHOICE=$(whiptail --title "Ollama Model Storage" \
--radiolist "Models are large (5-50GB each). Choose a location.\nSPACE = select ENTER = confirm" \
$((10 + ${#_MPTS[@]})) 72 $((1 + ${#_MPTS[@]})) \
"${WHIP_STORAGE[@]}" 3>&1 1>&2 2>&3) || STORAGE_CHOICE="volume"
STORAGE_CHOICE="${STORAGE_CHOICE//\"/}"
if [[ "$STORAGE_CHOICE" == "volume" ]]; then
OLLAMA_STORAGE="volume"
else
OLLAMA_HOST_PATH="${STORAGE_CHOICE}/ollama-models"
OLLAMA_STORAGE="bind"
ok "Ollama models → $OLLAMA_HOST_PATH"
fi
else
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 " 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
OLLAMA_HOST_PATH="${STORAGE_CHOICE%/}"
[[ -z "$OLLAMA_HOST_PATH" ]] && die "No path entered."
OLLAMA_STORAGE="bind"
fi
fi
unset _MPTS _MP _i WHIP_STORAGE
fi
# ── Q3: Storage for Kiwix ZIMs ────────────────────────────────────────────────
KIWIX_DIR="$BASE/kiwix" # default
if $SVC_KIWIX; then
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
)
if command -v whiptail &>/dev/null; then
WHIP_KIWIX=("default" "Default: $KIWIX_DIR" ON)
for _i in "${!_MPTS[@]}"; do
_mp_path=$(awk '{print $1}' <<< "${_MPTS[$_i]}")
_mp_avail=$(awk '{print $2}' <<< "${_MPTS[$_i]}")
WHIP_KIWIX+=("$_mp_path" "${_mp_path} (${_mp_avail} free)" OFF)
done
_KIWIX_SEL=$(whiptail --title "Kiwix ZIM Storage" \
--radiolist "ZIMs are large — Wikipedia alone ~46GB, total ~130GB.\nSPACE = select ENTER = confirm" \
$((10 + ${#_MPTS[@]})) 72 $((1 + ${#_MPTS[@]})) \
"${WHIP_KIWIX[@]}" 3>&1 1>&2 2>&3) || _KIWIX_SEL="default"
_KIWIX_SEL="${_KIWIX_SEL//\"/}"
if [[ "$_KIWIX_SEL" != "default" ]]; then
KIWIX_DIR="${_KIWIX_SEL}/kiwix"
ok "Kiwix ZIMs → $KIWIX_DIR"
fi
else
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 " 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
KIWIX_DIR="${KIWIX_CHOICE%/}"
fi
fi
unset _MPTS _MP _i _KIWIX_CHOICE WHIP_KIWIX
# ── 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" \
28 76 22 \
"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)" \
"askubuntu" "$(printf '%-30s %7s' 'Ask Ubuntu' '~1 GB')" "$(_zimon askubuntu.com_en_all)" \
"superuser" "$(printf '%-30s %7s' 'Super User' '~1 GB')" "$(_zimon superuser.com_en_all)" \
"unixse" "$(printf '%-30s %7s' 'Unix & Linux SE' '~500 MB')" "$(_zimon unix.stackexchange.com_en_all)" \
"serverfault" "$(printf '%-30s %7s' 'Server Fault' '~500 MB')" "$(_zimon serverfault.com_en_all)" \
"devdocs" "$(printf '%-30s %7s' 'DevDocs (API docs)' '~1 GB')" "$(_zimon devdocs_en_zig)" \
"archlinux" "$(printf '%-30s %7s' 'Arch Linux Wiki' '~30 MB')" "$(_zimon archlinux_en_all)" \
"freecodecamp" "$(printf '%-30s %7s' 'FreeCodeCamp' 'small')" "$(_zimon freecodecamp_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)" \
"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):"
echo " ── Dev & Sysadmin (most useful for AI coding) ──"
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 "Ask Ubuntu" "~1 GB" "$(_zim_tag askubuntu.com_en_all)"
printf " %2s) %-26s %7s %s\n" 4 "Super User" "~1 GB" "$(_zim_tag superuser.com_en_all)"
printf " %2s) %-26s %7s %s\n" 5 "Unix & Linux SE" "~500 MB" "$(_zim_tag unix.stackexchange.com_en_all)"
printf " %2s) %-26s %7s %s\n" 6 "Server Fault" "~500 MB" "$(_zim_tag serverfault.com_en_all)"
printf " %2s) %-26s %7s %s\n" 7 "DevDocs (API docs)" "~1 GB" "$(_zim_tag devdocs_en_zig)"
printf " %2s) %-26s %7s %s\n" 8 "Arch Linux Wiki" "~30 MB" "$(_zim_tag archlinux_en_all)"
printf " %2s) %-26s %7s %s\n" 9 "FreeCodeCamp" "small" "$(_zim_tag freecodecamp_en_all)"
echo " ── Reference ──"
printf " %2s) %-26s %7s %s\n" 10 "Wiktionary" "~2 GB" "$(_zim_tag wiktionary_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 11 "Wikibooks" "~500 MB" "$(_zim_tag wikibooks_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 12 "Wikisource" "~4 GB" "$(_zim_tag wikisource_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 13 "Wikivoyage" "~200 MB" "$(_zim_tag wikivoyage_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 14 "Wikiversity" "~500 MB" "$(_zim_tag wikiversity_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 15 "WikiNews" "~300 MB" "$(_zim_tag wikinews_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 16 "Wikiquote" "~300 MB" "$(_zim_tag wikiquote_en_all_nopic)"
printf " %2s) %-26s %7s %s\n" 17 "Vikidia (kids K-8)" "~66 MB" "$(_zim_tag vikidia_en_all_nopic)"
echo " ── Other ──"
printf " %2s) %-26s %7s %s\n" 18 "TED Talks" "~5 GB" "$(_zim_tag ted_mul_youth)"
printf " %2s) %-26s %7s %s\n" 19 "PhET Simulations" "~500 MB" "$(_zim_tag phet_en_all)"
printf " %2s) %-26s %7s %s\n" 20 "iFixit" "~2 GB" "$(_zim_tag ifixit_en_all)"
printf " %2s) %-26s %7s %s\n" 21 "LibreTexts" "varies" "$(_zim_tag libretexts.org_en)"
printf " %2s) %-26s %7s %s\n" 22 "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:+ }askubuntu" ;;
4) ZIM_PICKS+="${ZIM_PICKS:+ }superuser" ;;
5) ZIM_PICKS+="${ZIM_PICKS:+ }unixse" ;;
6) ZIM_PICKS+="${ZIM_PICKS:+ }serverfault" ;;
7) ZIM_PICKS+="${ZIM_PICKS:+ }devdocs" ;;
8) ZIM_PICKS+="${ZIM_PICKS:+ }archlinux" ;;
9) ZIM_PICKS+="${ZIM_PICKS:+ }freecodecamp" ;;
10) ZIM_PICKS+="${ZIM_PICKS:+ }wiktionary" ;;
11) ZIM_PICKS+="${ZIM_PICKS:+ }wikibooks" ;;
12) ZIM_PICKS+="${ZIM_PICKS:+ }wikisource" ;;
13) ZIM_PICKS+="${ZIM_PICKS:+ }wikivoyage" ;;
14) ZIM_PICKS+="${ZIM_PICKS:+ }wikiversity" ;;
15) ZIM_PICKS+="${ZIM_PICKS:+ }wikinews" ;;
16) ZIM_PICKS+="${ZIM_PICKS:+ }wikiquote" ;;
17) ZIM_PICKS+="${ZIM_PICKS:+ }vikidia" ;;
18) ZIM_PICKS+="${ZIM_PICKS:+ }ted" ;;
19) ZIM_PICKS+="${ZIM_PICKS:+ }phet" ;;
20) ZIM_PICKS+="${ZIM_PICKS:+ }ifixit" ;;
21) ZIM_PICKS+="${ZIM_PICKS:+ }libretexts" ;;
22) 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
AUTO_SUBNET=$(echo "$LOCAL_IP" | awk -F. '{print $1"."$2"."$3".0/24"}')
if command -v whiptail &>/dev/null; then
LAN_INPUT=$(whiptail --title "Firewall — LAN Access" \
--inputbox "Allow LAN access to all services.\n\nYour detected subnet:" \
10 60 "$AUTO_SUBNET" 3>&1 1>&2 2>&3) || LAN_INPUT=""
else
echo ""
echo -e " ${BOLD}[4b/6] Firewall — allow LAN access to services${NC}"
read -rp " LAN subnet [${AUTO_SUBNET}]: " LAN_INPUT
fi
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: model_file_size + ~1.5GB overhead for KV cache/CUDA vs VRAM
# Ollama has no hard limits — silently offloads to CPU when over VRAM
speed_label() {
local mgb="$1" # approximate Q4 file size in GB
local needed=$(( mgb + 2 )) # +2GB for KV cache, attention, CUDA overhead
if [[ "$VRAM_GB" -eq 0 ]]; then
printf "CPU only — very slow"
elif (( needed <= VRAM_GB )); then
printf "✓ fast — fits in VRAM (~%dGB needed)" "$needed"
elif (( needed <= VRAM_GB + 2 )); then
printf "~ tight — ~%dGB needed, may spill to CPU" "$needed"
elif (( needed <= VRAM_GB + 8 )); then
printf "✗ slow — ~%dGB needed, partial CPU offload" "$needed"
else
printf "✗ very slow — ~%dGB needed, heavy CPU offload" "$needed"
fi
}
if command -v whiptail &>/dev/null; then
MODEL_PREF=$(whiptail --title "Model Selection — ${GPU_NAME:-No GPU} (${VRAM_GB}GB VRAM)" \
--radiolist "Choose model origin preference.\nSPACE = select ENTER = confirm" \
14 78 4 \
"2" "Performance-first — Qwen 3.5 (top benchmarks, vision+code)" ON \
"1" "Western-only — Codestral · Phi4 · Mistral 7B" OFF \
"3" "Mixed — Western chat + Qwen 3.5 coding" OFF \
"4" "Custom — enter model names manually" OFF \
3>&1 1>&2 2>&3) || MODEL_PREF="2"
MODEL_PREF="${MODEL_PREF//\"/}"
else
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 — Qwen 3.5 (Feb 2026, top benchmarks, vision+code)"
echo " 3) Mixed — Western for chat, Qwen 3.5 for coding"
echo " 4) Custom — enter model names manually"
echo ""
read -rp " Choice [2]: " MODEL_PREF
MODEL_PREF="${MODEL_PREF:-2}"
fi
# Q4_K_M approximate weights in GB: 4B=2.5, 7B=4, 9B=5.5, 14B=9, 22B=13, 27B=17, 35B-MoE=12, 70B=41
# VRAM-based recommendation (soft — shown as suggestion only)
case "$MODEL_PREF" in
2) # Performance (Qwen 3.5)
if [[ "$TOTAL_VRAM" -ge 28 ]]; then REC_TIER="27B"
elif [[ "$TOTAL_VRAM" -ge 14 ]]; then REC_TIER="35B"
elif [[ "$TOTAL_VRAM" -ge 8 ]]; then REC_TIER="9B"
else REC_TIER="4B"
fi
;;
*) # Western / Mixed
if [[ "$TOTAL_VRAM" -ge 40 ]]; then REC_TIER="70B"
elif [[ "$TOTAL_VRAM" -ge 12 ]]; then REC_TIER="35B"
elif [[ "$TOTAL_VRAM" -ge 8 ]]; then REC_TIER="14B"
else REC_TIER="7B"
fi
;;
esac
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 (Qwen 3.5 — Feb 2026)
_TIER_NAMES=(4B 9B 35B 27B)
printf " %-4s %-8s %-42s %s\n" "1)" "4B" "qwen3.5:4b (chat+code)" "$(speed_label 2)"
printf " %-4s %-8s %-42s %s\n" "2)" "9B" "qwen3.5:9b (chat+code)" "$(speed_label 5)"
printf " %-4s %-8s %-42s %s\n" "3)" "35B" "qwen3.5-35b-a3b (MoE, 3B active)" "$(speed_label 12)"
printf " %-4s %-8s %-42s %s\n" "4)" "27B" "qwen3.5:27b (dense, A- quality)" "$(speed_label 17)"
;;
3) # Mixed (Western chat + Qwen 3.5 code)
_TIER_NAMES=(7B 14B 35B 70B)
printf " %-4s %-8s %-42s %s\n" "1)" "7B" "mistral:7b + qwen3.5:4b" "$(speed_label 4)"
printf " %-4s %-8s %-42s %s\n" "2)" "14B" "phi4:14b + qwen3.5:9b" "$(speed_label 9)"
printf " %-4s %-8s %-42s %s\n" "3)" "35B" "phi4:14b + qwen3.5-35b-a3b" "$(speed_label 19)"
printf " %-4s %-8s %-42s %s\n" "4)" "70B" "llama3.3:70b + qwen3.5-35b-a3b" "$(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
if command -v whiptail &>/dev/null; then
_in=$(whiptail --title "Custom Models — Fast/Chat" \
--inputbox "Fast/chat model (small, quick responses):" \
9 60 "$FAST_MODEL" 3>&1 1>&2 2>&3) && FAST_MODEL="${_in:-$FAST_MODEL}"
_in=$(whiptail --title "Custom Models — Smart Chat" \
--inputbox "Smart chat model (main model for complex tasks):" \
9 60 "$CHAT_MODEL" 3>&1 1>&2 2>&3) && CHAT_MODEL="${_in:-$CHAT_MODEL}"
_in=$(whiptail --title "Custom Models — Code" \
--inputbox "Code model:" \
9 60 "$CODE_MODEL" 3>&1 1>&2 2>&3) && CODE_MODEL="${_in:-$CODE_MODEL}"
REASON_MODEL=$(whiptail --title "Custom Models — Reasoning" \
--inputbox "Reasoning model (leave blank to skip):" \
9 60 "" 3>&1 1>&2 2>&3) || REASON_MODEL=""
else
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
fi
else
if command -v whiptail &>/dev/null; then
# Build whiptail radiolist with recommended tier pre-selected
WHIP_TIERS=()
declare -A _TIER_LABELS
case "$MODEL_PREF" in
1) _TIER_LABELS=([7B]="mistral:7b + codellama:7b" [14B]="phi4:14b + starcoder2:15b" [22B]="phi4:14b + codestral:22b" [70B]="llama3.3:70b + codestral:22b")
_TIER_SPEEDS=([7B]="$(speed_label 4)" [14B]="$(speed_label 9)" [22B]="$(speed_label 13)" [70B]="$(speed_label 41)") ;;
2) _TIER_LABELS=([4B]="qwen3.5:4b (chat+code)" [9B]="qwen3.5:9b (chat+code)" [35B]="qwen3.5-35b-a3b (MoE, 3B active)" [27B]="qwen3.5:27b (dense)")
_TIER_SPEEDS=([4B]="$(speed_label 2)" [9B]="$(speed_label 5)" [35B]="$(speed_label 12)" [27B]="$(speed_label 17)") ;;
3) _TIER_LABELS=([7B]="mistral:7b + qwen3.5:4b" [14B]="phi4:14b + qwen3.5:9b" [35B]="phi4:14b + qwen3.5-35b-a3b" [70B]="llama3.3:70b + qwen3.5-35b-a3b")
_TIER_SPEEDS=([7B]="$(speed_label 4)" [14B]="$(speed_label 9)" [35B]="$(speed_label 19)" [70B]="$(speed_label 41)") ;;
esac
for _tn in "${_TIER_NAMES[@]}"; do
_onoff="OFF"; [[ "$_tn" == "$REC_TIER" ]] && _onoff="ON"
WHIP_TIERS+=("$_tn" "${_TIER_LABELS[$_tn]} | ${_TIER_SPEEDS[$_tn]}" "$_onoff")
done
TIER_PICK=$(whiptail --title "Model Size — ${VRAM_GB}GB GPU" \
--radiolist "Recommended tier pre-selected based on your GPU.\nSPACE = select ENTER = confirm" \
14 90 4 \
"${WHIP_TIERS[@]}" 3>&1 1>&2 2>&3) || TIER_PICK="$REC_TIER"
TIER_PICK="${TIER_PICK//\"/}"
unset WHIP_TIERS _TIER_LABELS _TIER_SPEEDS
else
read -rp " Choose tier [$REC_NUM]: " _TIER_INPUT
_TIER_INPUT="${_TIER_INPUT:-$REC_NUM}"
if [[ "$_TIER_INPUT" =~ ^[1-4]$ ]]; then
TIER_PICK="${_TIER_NAMES[$((_TIER_INPUT-1))]}"
else
TIER_PICK="$_TIER_INPUT"
fi
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 (Qwen 3.5)
2:4B) FAST_MODEL="qwen3.5:4b"; CHAT_MODEL="qwen3.5:4b"; CODE_MODEL="qwen3.5:4b"; REASON_MODEL="" ;;
2:9B) FAST_MODEL="qwen3.5:4b"; CHAT_MODEL="qwen3.5:9b"; CODE_MODEL="qwen3.5:9b"; REASON_MODEL="" ;;
2:35B) FAST_MODEL="qwen3.5:4b"; CHAT_MODEL="qwen3.5-35b-a3b"; CODE_MODEL="qwen3.5-35b-a3b"; REASON_MODEL="" ;;
2:27B) FAST_MODEL="qwen3.5:9b"; CHAT_MODEL="qwen3.5:27b"; CODE_MODEL="qwen3.5:27b"; REASON_MODEL="" ;;
# Mixed (Western chat + Qwen 3.5 code)
3:7B) FAST_MODEL="mistral:7b"; CHAT_MODEL="mistral:7b"; CODE_MODEL="qwen3.5:4b"; REASON_MODEL="" ;;
3:14B) FAST_MODEL="mistral:7b"; CHAT_MODEL="phi4:14b"; CODE_MODEL="qwen3.5:9b"; REASON_MODEL="phi4:14b" ;;
3:35B) FAST_MODEL="mistral:7b"; CHAT_MODEL="phi4:14b"; CODE_MODEL="qwen3.5-35b-a3b"; REASON_MODEL="phi4:14b" ;;
3:70B) FAST_MODEL="mistral:7b"; CHAT_MODEL="llama3.3:70b"; CODE_MODEL="qwen3.5-35b-a3b"; REASON_MODEL="llama3.3:70b" ;;
*)
warn "Unrecognised tier '$TIER_PICK' — keeping detected defaults"
;;
esac
fi
# Show selected models and ask about download
_MODEL_SUMMARY="Fast chat: $FAST_MODEL\nSmart chat: $CHAT_MODEL\nCode: $CODE_MODEL"
[[ -n "$REASON_MODEL" ]] && _MODEL_SUMMARY+="\nReasoning: $REASON_MODEL"
_MODEL_SUMMARY+="\nEmbed (RAG): $EMBED_MODEL"
if command -v whiptail &>/dev/null; then
if whiptail --title "Download Models Now?" \
--yesno "Models selected:\n\n$_MODEL_SUMMARY\n\nDownload these models now? (can take 10-40 min)" \
14 60 3>&1 1>&2 2>&3; then
PULL_MODELS=true
fi
else
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
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_COMFYUI && _svcs+=" · ComfyUI"
$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 ""
if command -v whiptail &>/dev/null; then
whiptail --title "Ready to Install" \
--yesno "Everything above will be installed and configured.\n\nProceed?" \
9 50 3>&1 1>&2 2>&3 || { echo "Aborted."; exit 0; }
else
read -rp " Proceed? [Y/n]: " CONFIRM
[[ "${CONFIRM,,}" == "n" ]] && echo "Aborted." && exit 0
fi
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 \
comfyui-data comfyui-output 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
duckduckgo-search
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 and Gitea↔GitHub sync
GITHUB_TOKEN=your-github-token-here
# Gitea URL — used by sync script (default: http://localhost:3001)
GITEA_URL=http://$LOCAL_IP:3001
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 <service>
# Stop one service: docker compose stop <service>
# Start one service: docker compose up -d <service>
# Follow all logs: docker compose logs -f
# Follow one service logs: docker compose logs -f <service>
# 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 comfyui 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=$CTX # auto-set by detected VRAM
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_MAX_LOADED_MODELS=1
- OLLAMA_KV_CACHE_TYPE=$OLLAMA_KV_CACHE # q8_0 halves KV cache; q4_0 = 1/3 size
- OLLAMA_FLASH_ATTENTION=$OLLAMA_FLASH # tiled attention: less VRAM, no quality loss
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:-}
- ENABLE_RAG_WEB_SEARCH=true
- RAG_WEB_SEARCH_ENGINE=duckduckgo
$( $SVC_COMFYUI && cat <<'IMGENV'
- ENABLE_IMAGE_GENERATION=true
- IMAGE_GENERATION_ENGINE=comfyui
- COMFYUI_BASE_URL=http://comfyui:8188
IMGENV
)
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=$CHAT_MODEL
- 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
- $SCRIPT_DIR/gitea-github-sync.sh:/app/gitea-github-sync.sh:ro
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
- KIWIX_URL=http://kiwix:80
command: >
bash -c "apt-get update -qq &&
apt-get install -y --no-install-recommends git ripgrep curl &&
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=$INVOKEAI_PRECISION
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# ── ComfyUI — Node-based image generation ──────────────────────────────────
comfyui:
image: ghcr.io/ai-dock/comfyui:latest
container_name: comfyui
restart: unless-stopped
ports:
- "0.0.0.0:8188:8188"
volumes:
- comfyui-models:/opt/ComfyUI/models
- $BASE/comfyui-output:/opt/ComfyUI/output
- $BASE/comfyui-data:/opt/ComfyUI/custom_nodes
environment:
- CLI_ARGS=--listen 0.0.0.0
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:
comfyui-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" "8188:ComfyUI" "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_COMFYUI && _START_URLS+=$'echo " ComfyUI → http://'"$LOCAL_IP"$':8188"\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 }
comfyui.yourdomain.com { reverse_proxy $LOCAL_IP:8188 }
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_COMFYUI && COMPOSE_SERVICES+=" comfyui"
$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
# ── Install image generation base model (GPU-aware) ──────────────────────────
if $INSTALL_AI && ($SVC_INVOKEAI || $SVC_COMFYUI) && $PULL_MODELS; then
section "Image Generation Models"
info "GPU: ${TOTAL_VRAM}GB VRAM → $IMG_TIER"
if [[ -n "$IMG_DEFAULT" ]]; then
info "Auto-installing recommended model: $IMG_DEFAULT"
info "Supported on your GPU: $IMG_MODELS"
bash "$SCRIPT_DIR/setup-image-models.sh" --auto
else
warn "Not enough VRAM for image generation models (need at least 4GB)"
fi
elif $INSTALL_AI && ($SVC_INVOKEAI || $SVC_COMFYUI); then
info "Skipping image model install — run later: bash $SCRIPT_DIR/setup-image-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")
ASKUBUNTU=$(latest_zim "$MIRROR/stack_exchange" "askubuntu.com_en_all")
SUPERUSER=$(latest_zim "$MIRROR/stack_exchange" "superuser.com_en_all")
UNIXSE=$(latest_zim "$MIRROR/stack_exchange" "unix.stackexchange.com_en_all")
SERVERFAULT=$(latest_zim "$MIRROR/stack_exchange" "serverfault.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" ;;
askubuntu) dl_zim "stack_exchange" "$ASKUBUNTU" "Ask Ubuntu" ;;
superuser) dl_zim "stack_exchange" "$SUPERUSER" "Super User" ;;
unixse) dl_zim "stack_exchange" "$UNIXSE" "Unix & Linux SE" ;;
serverfault) dl_zim "stack_exchange" "$SERVERFAULT" "Server Fault" ;;
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 "stack_exchange" "$ASKUBUNTU" "Ask Ubuntu"
dl_zim "stack_exchange" "$SUPERUSER" "Super User"
dl_zim "stack_exchange" "$UNIXSE" "Unix & Linux SE"
dl_zim "stack_exchange" "$SERVERFAULT" "Server Fault"
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_COMFYUI && echo -e " ${CYAN}ComfyUI${NC} → http://$LOCAL_IP:8188"
$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/"
$SVC_GITEA && {
echo ""
echo -e " ${YELLOW}Gitea ↔ GitHub sync:${NC}"
echo " First time: $SCRIPT_DIR/gitea-github-sync.sh --init"
echo " Sync now: $SCRIPT_DIR/gitea-github-sync.sh"
echo " Auto (6h): $SCRIPT_DIR/gitea-github-sync.sh --install-timer"
echo " Via MCP: gitea_github_sync(mode='all')"
}
fi
if $INSTALL_AI; then
echo ""
echo -e " ${YELLOW}Recommended Open WebUI Functions (install from Admin → Functions → ):${NC}"
echo " Context tracker: https://openwebui.com/f/centrisic/context_tracker"
echo " → Shows tokens used vs available, progress bar, context % remaining"
echo " Context compaction: https://openwebui.com/f/projectmoon/checkpoint_summarization_filter"
echo " → Auto-summarizes old messages when context fills up (like Claude)"
echo " Auto Memory: Install from Admin → Functions → Discover → search 'Auto Memory'"
echo " → Automatically stores relevant info as persistent memories across chats"
($SVC_COMFYUI || $SVC_INVOKEAI) && {
echo ""
echo -e " ${YELLOW}Image Generation — GPU: ${TOTAL_VRAM}GB → $IMG_TIER${NC}"
echo " Install base models (detects your GPU automatically):"
echo " ./setup-image-models.sh # interactive"
echo " ./setup-image-models.sh --auto # install recommended default"
echo " Supports: $IMG_MODELS"
}
$SVC_COMFYUI && {
echo ""
echo -e " ${YELLOW}ComfyUI → Open WebUI (chat-integrated image gen):${NC}"
echo " 1. Run ./setup-image-models.sh to install a base model"
echo " 2. In ComfyUI: Settings (gear) → enable 'Dev Mode' → Save workflow as 'API Format'"
echo " 3. In Open WebUI: Admin → Settings → Images"
echo " Engine: ComfyUI | URL: http://comfyui:8188 (already set via env vars)"
echo " 4. Import your workflow JSON and map the prompt/output nodes"
echo " 5. Ask any model to 'generate an image of...' — it will use ComfyUI"
}
$SVC_INVOKEAI && {
echo ""
echo -e " ${YELLOW}InvokeAI (standalone UI — inpainting, img2img, LoRA):${NC}"
echo " InvokeAI runs at http://$LOCAL_IP:9090"
echo " For inpainting: Unified Canvas tab → brush over area → describe replacement"
echo " Import LoRAs: ./invokeai-import-lora.sh <file.safetensors>"
$SVC_COMFYUI || echo " For Open WebUI chat integration, enable ComfyUI in the setup wizard"
}
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 ""