Files
local-ai/laptop_full_setup.sh
T
Claude 2912208f87 fix kiwix crash-loop when no ZIM files present
Replace subshell ls expansion (which produces no args when /data is empty)
with a conditional: serve ZIM files if they exist, otherwise sleep infinity
so the container stays up gracefully until ZIM files are added.

https://claude.ai/code/session_012gDnantBmFTWZGCiKyjazx
2026-03-21 18:25:04 +00:00

1374 lines
62 KiB
Bash
Executable File
Raw 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 · 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 <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 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:8080"
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 ""