Files
local-ai/laptop_full_setup.sh
T
Claude b090b902c0 fix whiptail capture bug (2>/dev/null killed selections); add artic; fix ENABLE_MAP
Root cause of all 85 engines being disabled:
  `3>&1 1>&2 2>&3 2>/dev/null` — the trailing `2>/dev/null` overwrites FD2
  with /dev/null AFTER the FD swap, destroying the pipe that whiptail writes
  selections to. User selections were silently discarded every time. Remove it.

Also: if whiptail exits 0 but returns empty string (all items unchecked),
fall back to pre-checked defaults rather than disabling everything.

ENABLE_MAP: unbound variable (configure-searxng-safesearch.sh):
  bash < 4.4 treats `${!empty_assoc[@]}` as unbound under `set -u`.
  Wrap for loops and mark_disabled key-check with set +u / set -u.

Add artic (Art Institute of Chicago) to image engine menus.
Add yandex images (defaults OFF for moderate/strict, same as yandex).
Update _SX_AUTOFF to include "yandex images" so re-enabling it works.
Update configure script engine category lists to match expanded menus.

https://claude.ai/code/session_012gDnantBmFTWZGCiKyjazx
2026-03-21 05:45:30 +00:00

1627 lines
77 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 · SearXNG
# Gitea · InvokeAI · Portainer
# • Kiwix — Offline Wikipedia, Stack Overflow, Arch Wiki, etc.
#
# Usage:
# ./laptop_full_setup.sh — interactive (asks everything upfront)
# ./laptop_full_setup.sh --force — overwrite existing config files too
#
# GPU: Ollama auto-detects VRAM — works with any NVIDIA GPU
# =============================================================================
set -euo pipefail
# ── colours ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${CYAN}[..]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
die() { echo -e "${RED}[XX]${NC} $*" >&2; exit 1; }
section() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; }
# ── args ──────────────────────────────────────────────────────────────────────
FORCE=false
for arg in "$@"; do [[ "$arg" == "--force" ]] && FORCE=true; done
# ── config ────────────────────────────────────────────────────────────────────
BASE="$HOME/docker/ai-stack"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' \
|| hostname -I | awk '{print $1}')
[[ -z "$LOCAL_IP" ]] && read -rp "Enter your LAN IP: " LOCAL_IP
# Models (defaults, may be adjusted below based on VRAM)
EMBED_MODEL="nomic-embed-text"
CHAT_MODEL="qwen2.5:14b"
CODE_MODEL="qwen2.5-coder:7b"
FAST_MODEL="qwen2.5:7b"
# ── detect GPU ────────────────────────────────────────────────────────────────
VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
| head -1 | awk '{printf "%d", $1/1024}' 2>/dev/null || echo "0")
GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo "None")
if [[ "$VRAM_GB" -ge 14 ]]; then
CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:14b"
GPU_TIER="16GB VRAM — 14B models"
elif [[ "$VRAM_GB" -ge 8 ]]; then
CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:7b"
GPU_TIER="8GB VRAM — 14B chat, 7B code"
elif [[ "$VRAM_GB" -ge 4 ]]; then
CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b"
GPU_TIER="6GB VRAM — 7B models"
elif [[ "$VRAM_GB" -gt 0 ]]; then
CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b"
GPU_TIER="${VRAM_GB}GB VRAM — 7B models"
else
CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b"
GPU_TIER="CPU only — 7B models (slow)"
fi
# ── new vs update ─────────────────────────────────────────────────────────────
IS_UPDATE=false
[[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true
# =============================================================================
# QUESTIONS UPFRONT — answer everything, then walk away
# =============================================================================
section "Local AI Stack — Setup Wizard"
echo ""
echo -e " ${BOLD}Answer the questions below, then leave it overnight.${NC}"
echo -e " Everything will install and download while you sleep."
echo ""
info "Machine : $(hostname)"
info "LAN IP : $LOCAL_IP"
info "GPU : ${GPU_NAME} (${VRAM_GB}GB VRAM)"
$IS_UPDATE && warn "Existing install found. Config files kept unless --force is passed."
# ── Q1: 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_SEARXNG=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)" \
"searxng" "SearXNG — Private local web search engine" "$(existing_svc searxng)" \
"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_SEARXNG=false; [[ "$SELECTED_SVCS" == *'"searxng"'* ]] && SVC_SEARXNG=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; _S=$SVC_SEARXNG
_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. SearXNG — Private web search\n" "$($_S && echo '*' || echo ' ')"
printf " [%s] 5. Gitea — Self-hosted Git\n" "$($_G && echo '*' || echo ' ')"
printf " [%s] 6. InvokeAI — Image generation\n" "$($_I && 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) $_S && _S=false || _S=true ;;
5) $_G && _G=false || _G=true ;;
6) $_I && _I=false || _I=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_SEARXNG=$_S
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
# ── Q6: SearXNG safe-search & engine selection ────────────────────────────────
SEARXNG_SAFE_LEVEL="none"
SEARXNG_SAFE_INT=0
SEARXNG_DISABLE_ENGINES=""
SEARXNG_ENABLE_ENGINES=""
if $INSTALL_AI && $SVC_SEARXNG; then
echo ""
echo -e " ${BOLD}[6/6] SearXNG safe-search${NC}"
echo " 0) None — all results, no filtering (default)"
echo " 1) Moderate — filter explicit content"
echo " 2) Strict — block all explicit content"
echo ""
read -rp " Choice [0]: " _SAFE_PICK
case "${_SAFE_PICK:-0}" in
1) SEARXNG_SAFE_LEVEL="moderate"; SEARXNG_SAFE_INT=1 ;;
2) SEARXNG_SAFE_LEVEL="strict"; SEARXNG_SAFE_INT=2 ;;
*) SEARXNG_SAFE_LEVEL="none"; SEARXNG_SAFE_INT=0 ;;
esac
# Default ON/OFF for an engine given the chosen safe-search level.
# Engines without safe-search support default OFF for moderate/strict.
_sxst() {
[[ "$SEARXNG_SAFE_LEVEL" == "none" ]] && echo ON && return
case "$1" in
mojeek|"mojeek images"|"mojeek news"|\
yandex|"yandex images"|\
baidu|naver|\
invidious|piped|peertube|sepiasearch|\
vimeo|bitchute|rumble|odysee|\
imgur|deviantart|artstation) echo OFF ;;
*) echo ON ;;
esac
}
# Whiptail checklist — prints selected engine names (one per line).
# ESC returns the engines that were pre-checked ON (keeps defaults unchanged).
# Args: "Title" name "Desc" ON|OFF [name "Desc" ON|OFF ...]
_sxmenu() {
local _t="$1"; shift
local -a _n=() _s=() _a=(); local _i=1
while [[ $# -ge 3 ]]; do
_n+=("$1"); _s+=("$3")
_a+=("$_i" "$(printf '%-26s %s' "$1" "$2")" "$3")
_i=$(( _i + 1 )); shift 3
done
local _h=$(( ${#_n[@]} + 9 > 24 ? 24 : ${#_n[@]} + 9 ))
local _lh=$(( ${#_n[@]} < 14 ? ${#_n[@]} : 14 ))
local _o _rc _j
# Note: NO 2>/dev/null after the FD swap — that redirect would overwrite
# the pipe (FD2 after swap) with /dev/null, silently discarding selections.
_o=$(whiptail --backtitle "SearXNG — $SEARXNG_SAFE_LEVEL" \
--title "$_t" --checklist \
"SPACE = toggle ENTER = confirm ESC = use defaults" \
"$_h" 76 "$_lh" "${_a[@]}" 3>&1 1>&2 2>&3) && _rc=0 || _rc=$?
if [[ $_rc -ne 0 ]] || [[ -z "${_o//\"}" ]]; then
# ESC / cancel / empty selection → use pre-checked defaults
for _j in "${!_n[@]}"; do
[[ "${_s[$_j]}" == "ON" ]] && printf '%s\n' "${_n[$_j]}"
done
return
fi
for _id in $_o; do _id="${_id//\"/}"; printf '%s\n' "${_n[$(( _id-1 ))]}"; done
}
# Engines auto-disabled by configure script for moderate/strict.
# If user re-enables one in the menu we pass --enable-engines to override.
_SX_AUTOFF=(mojeek yandex "yandex images" baidu naver invidious piped peertube sepiasearch)
if command -v whiptail &>/dev/null && [[ -t 0 ]]; then
# ── Web search ─────────────────────────────────────────────────────────
_SX_W=$(_sxmenu "Web Search Engines" \
"google" "Google" ON \
"bing" "Microsoft Bing" ON \
"duckduckgo" "DuckDuckGo" ON \
"brave" "Brave Search" ON \
"startpage" "Startpage (Google proxy)" ON \
"qwant" "Qwant" ON \
"yahoo" "Yahoo" ON \
"ecosia" "Ecosia" ON \
"presearch" "Presearch" ON \
"yep" "Yep" ON \
"wiby" "Wiby (classic/indie web)" ON \
"mojeek" "Mojeek [no safe-search]" "$(_sxst mojeek)" \
"yandex" "Yandex [unreliable filter]" "$(_sxst yandex)" \
"baidu" "Baidu [no safe-search]" "$(_sxst baidu)" \
"naver" "Naver (Korean) [no safe-search]" "$(_sxst naver)")
# ── Images ─────────────────────────────────────────────────────────────
_SX_I=$(_sxmenu "Image Search Engines" \
"google images" "Google Images" ON \
"bing images" "Bing Images" ON \
"duckduckgo images" "DuckDuckGo Images" ON \
"brave images" "Brave Images" ON \
"qwant images" "Qwant Images" ON \
"startpage images" "Startpage Images" ON \
"mojeek images" "Mojeek Images [no safe-search]" "$(_sxst 'mojeek images')" \
"presearch images" "Presearch Images" ON \
"openverse" "Openverse (open license)" ON \
"unsplash" "Unsplash (stock photos)" ON \
"pexels" "Pexels (stock photos)" ON \
"pixabay images" "Pixabay Images" ON \
"pinterest" "Pinterest" ON \
"flickr" "Flickr" ON \
"wikcommons.images" "Wikimedia Commons Images" ON \
"artic" "Art Institute of Chicago" ON \
"yandex images" "Yandex Images [no safe-search]" "$(_sxst 'yandex images')" \
"imgur" "Imgur [adult content]" "$(_sxst imgur)" \
"deviantart" "DeviantArt [adult content]" "$(_sxst deviantart)" \
"artstation" "ArtStation [adult content]" "$(_sxst artstation)" \
"adobe stock" "Adobe Stock" ON)
# ── Videos ─────────────────────────────────────────────────────────────
_SX_V=$(_sxmenu "Video Search Engines" \
"youtube" "YouTube" ON \
"bing videos" "Bing Videos" ON \
"brave videos" "Brave Videos" ON \
"duckduckgo videos" "DuckDuckGo Videos" ON \
"google videos" "Google Videos" ON \
"qwant videos" "Qwant Videos" ON \
"dailymotion" "Dailymotion" ON \
"media.ccc.de" "media.ccc.de (tech talks)" ON \
"wikcommons.videos" "Wikimedia Commons Videos" ON \
"vimeo" "Vimeo [no safe-search]" "$(_sxst vimeo)" \
"odysee" "Odysee [no safe-search]" "$(_sxst odysee)" \
"rumble" "Rumble [no safe-search]" "$(_sxst rumble)" \
"bitchute" "BitChute [no safe-search]" "$(_sxst bitchute)" \
"invidious" "Invidious (YT) [no safe-search]" "$(_sxst invidious)" \
"piped" "Piped (YT) [no safe-search]" "$(_sxst piped)" \
"peertube" "PeerTube [no safe-search]" "$(_sxst peertube)" \
"sepiasearch" "SepiaSearch [no safe-search]" "$(_sxst sepiasearch)")
# ── News ───────────────────────────────────────────────────────────────
_SX_N=$(_sxmenu "News Search Engines" \
"google news" "Google News" ON \
"bing news" "Bing News" ON \
"duckduckgo news" "DuckDuckGo News" ON \
"brave news" "Brave News" ON \
"qwant news" "Qwant News" ON \
"startpage news" "Startpage News" ON \
"presearch news" "Presearch News" ON \
"mojeek news" "Mojeek News [no safe-search]" "$(_sxst 'mojeek news')" \
"reuters" "Reuters" ON \
"yahoo news" "Yahoo News" ON \
"wikinews" "WikiNews" ON \
"yep news" "Yep News" ON)
# ── Reference & Knowledge ──────────────────────────────────────────────
_SX_R=$(_sxmenu "Reference & Knowledge" \
"wikipedia" "Wikipedia" ON \
"wikidata" "Wikidata" ON \
"wolframalpha" "Wolfram|Alpha" ON \
"ask" "Ask.com" ON \
"ddg definitions" "DuckDuckGo Definitions" ON \
"encyclopsearch" "Encyclopsearch" ON \
"wikibooks" "Wikibooks" ON \
"wikiquote" "Wikiquote" ON \
"wikisource" "Wikisource" ON \
"wikispecies" "Wikispecies" ON \
"wikiversity" "Wikiversity" ON \
"wikivoyage" "Wikivoyage" ON)
# ── Science ────────────────────────────────────────────────────────────
_SX_S=$(_sxmenu "Science / Academic" \
"arxiv" "arXiv (preprints)" ON \
"semantic scholar" "Semantic Scholar" ON \
"pubmed" "PubMed (medical)" ON \
"crossref" "Crossref (DOI/papers)" ON \
"base" "BASE (open access)" ON)
_SX_ALL=(
# Web
"google" "bing" "duckduckgo" "brave" "startpage" "qwant" "yahoo" "ecosia"
"presearch" "yep" "wiby"
"mojeek" "yandex" "baidu" "naver"
# Images
"google images" "bing images" "duckduckgo images" "brave images" "qwant images"
"startpage images" "mojeek images" "presearch images"
"openverse" "unsplash" "pexels" "pixabay images" "pinterest" "flickr"
"wikcommons.images" "artic" "yandex images" "imgur" "deviantart" "artstation" "adobe stock"
# Videos
"youtube" "bing videos" "brave videos" "duckduckgo videos" "google videos" "qwant videos"
"dailymotion" "media.ccc.de" "wikcommons.videos"
"vimeo" "odysee" "rumble" "bitchute"
"invidious" "piped" "peertube" "sepiasearch"
# News
"google news" "bing news" "duckduckgo news" "brave news" "qwant news"
"startpage news" "presearch news" "mojeek news"
"reuters" "yahoo news" "wikinews" "yep news"
# Reference
"wikipedia" "wikidata" "wolframalpha" "ask" "ddg definitions" "encyclopsearch"
"wikibooks" "wikiquote" "wikisource" "wikispecies" "wikiversity" "wikivoyage"
# Science
"arxiv" "semantic scholar" "pubmed" "crossref" "base"
)
_SX_SEL=$(printf '%s\n' "$_SX_W" "$_SX_I" "$_SX_V" "$_SX_N" "$_SX_R" "$_SX_S")
for _e in "${_SX_ALL[@]}"; do
if printf '%s\n' "$_SX_SEL" | grep -qxF "$_e"; then
# Selected — if auto-disabled by level, pass --enable-engines to override
for _ao in "${_SX_AUTOFF[@]}"; do
if [[ "$_e" == "$_ao" ]]; then
SEARXNG_ENABLE_ENGINES+="${SEARXNG_ENABLE_ENGINES:+,}${_e}"
break
fi
done
else
SEARXNG_DISABLE_ENGINES+="${SEARXNG_DISABLE_ENGINES:+,}${_e}"
fi
done
else
# Fallback: no whiptail / non-interactive terminal
echo ""
echo " (whiptail not available — text entry)"
echo " Web: google bing duckduckgo brave startpage qwant yahoo presearch"
echo " Imgs: google_images bing_images duckduckgo_images flickr imgur"
echo " Vids: youtube dailymotion vimeo rumble odysee bitchute"
echo " News: google_news bing_news reuters yahoo_news wikinews"
read -rp " Engines to disable (space-separated, Enter for defaults): " _ENGS
SEARXNG_DISABLE_ENGINES="${_ENGS// /,}"
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_SEARXNG && _svcs+=" · SearXNG"
$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"
if $INSTALL_AI && $SVC_SEARXNG; then
_sx="safe_search: $SEARXNG_SAFE_LEVEL"
if [[ -n "$SEARXNG_DISABLE_ENGINES" ]]; then
_ndis=$(tr ',' '\n' <<< "$SEARXNG_DISABLE_ENGINES" | grep -c .)
_sx+=" | ${_ndis} engines disabled"
fi
echo " ✓ SearXNG → $_sx"
fi
$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 searxng invokeai-data invokeai-outputs \
gitea portainer-data logs; do
mkdir -p "$BASE/$d"
done
# Kiwix ZIM dir — may be on a different drive
mkdir -p "$KIWIX_DIR"
# Ollama bind-mount dir (if using custom path)
[[ "$OLLAMA_STORAGE" == "bind" ]] && mkdir -p "$OLLAMA_HOST_PATH"
ok "Directories ready"
# =============================================================================
if $INSTALL_AI; then
section "Server Files"
# =============================================================================
# Copy Python servers from repo (always update — they are version-controlled)
cp "$SCRIPT_DIR/server.py" "$BASE/server.py" && ok "server.py"
cp "$SCRIPT_DIR/mcp_server.py" "$BASE/mcp_server.py" && ok "mcp_server.py"
# RAG dependencies
cat > "$BASE/requirements.txt" << 'REQ'
fastapi
uvicorn[standard]
httpx
pydantic
chromadb
pypdf
watchdog
python-multipart
REQ
ok "requirements.txt"
# MCP server dependencies
cat > "$BASE/mcp_requirements.txt" << 'REQ'
mcp[cli]
fastapi
uvicorn[standard]
httpx
REQ
ok "mcp_requirements.txt"
fi # INSTALL_AI
# =============================================================================
if $INSTALL_AI && $SVC_SEARXNG; then
section "SearXNG Config"
# =============================================================================
mkdir -p "$BASE/searxng"
_SXARGS=("$SEARXNG_SAFE_LEVEL")
[[ -n "$SEARXNG_DISABLE_ENGINES" ]] && _SXARGS+=(--disable-engines "$SEARXNG_DISABLE_ENGINES")
[[ -n "$SEARXNG_ENABLE_ENGINES" ]] && _SXARGS+=(--enable-engines "$SEARXNG_ENABLE_ENGINES")
BASE="$BASE" bash "$SCRIPT_DIR/configure-searxng-safesearch.sh" "${_SXARGS[@]}"
fi # INSTALL_AI && SVC_SEARXNG
# =============================================================================
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 \$BASE/.env to add API tokens.
# GPU: OLLAMA_NUM_GPU=999 means "use all available VRAM" — auto-adapts to any GPU.
services:
# ── Ollama — LLM inference ──────────────────────────────────────────────────
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
ports:
- "0.0.0.0:11434:11434"
volumes:
${OLLAMA_VOLUME_LINE}
environment:
- OLLAMA_NUM_GPU=999 # use all available VRAM (auto-detects GPU size)
- OLLAMA_NUM_CTX=8192 # lower to 4096 if you hit OOM
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_MAX_LOADED_MODELS=1
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 5
# ── Open WebUI — Chat interface ─────────────────────────────────────────────
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
ports:
- "0.0.0.0:3000:8080"
volumes:
- open-webui-data:/app/backend/data
environment:
- OLLAMA_BASE_URL=http://ollama:11434
- OPENAI_API_BASE_URL=http://rag-server:8001/v1
- OPENAI_API_KEY=local-rag-key
- ENABLE_OPENAI_API=true
- ENABLE_RAG_WEB_SEARCH=true
- RAG_WEB_SEARCH_ENGINE=searxng
- SEARXNG_QUERY_URL=http://searxng:8080/search?q=<query>&format=json&safesearch=${SEARXNG_SAFE_INT}
- RAG_WEB_SEARCH_RESULT_COUNT=5
- RAG_WEB_SEARCH_CONCURRENT_REQUESTS=10
- 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
# ── SearXNG — Private web search ───────────────────────────────────────────
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "0.0.0.0:8888:8080"
volumes:
- $BASE/searxng:/etc/searxng
cap_drop: [ALL]
cap_add: [CHOWN, SETGID, SETUID]
# ── Kiwix — Offline Wikipedia/docs ─────────────────────────────────────────
kiwix:
image: ghcr.io/kiwix/kiwix-serve:latest
container_name: kiwix
restart: unless-stopped
ports:
- "0.0.0.0:8181:8080"
volumes:
- $KIWIX_DIR:/data
entrypoint: ["sh", "-c"]
command: ["until ls /data/*.zim 2>/dev/null; do echo 'kiwix: waiting for ZIM files…'; sleep 60; done && exec kiwix-serve /data/*.zim"]
# ── Gitea — Self-hosted Git ─────────────────────────────────────────────────
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "0.0.0.0:3001:3000"
- "0.0.0.0:2222:22"
volumes:
- $BASE/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea/gitea.db
- GITEA__webhook__ALLOWED_HOST_LIST=rag-server,mcp-server
# ── InvokeAI — Image generation ────────────────────────────────────────────
invokeai:
image: ghcr.io/invoke-ai/invokeai:latest
container_name: invokeai
restart: unless-stopped
ports:
- "0.0.0.0:9090:9090"
volumes:
- invokeai-models:/invokeai/models
- $BASE/invokeai-outputs:/invokeai/outputs
- $BASE/invokeai-data:/invokeai/databases
environment:
- INVOKEAI_HOST=0.0.0.0
- INVOKEAI_PORT=9090
- INVOKEAI_PRECISION=float16
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# ── Portainer — Docker management UI ───────────────────────────────────────
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: unless-stopped
ports:
- "0.0.0.0:9000:9000"
- "0.0.0.0:9443:9443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- $BASE/portainer-data:/data
volumes:
${OLLAMA_VOLUMES_DECL}
open-webui-data:
invokeai-models:
COMPOSE
ok "docker-compose.yml written"
# =============================================================================
section "Firewall (UFW)"
# =============================================================================
if command -v ufw &>/dev/null; then
if [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; then
for port_comment in \
"3000:Open WebUI" "11434:Ollama" "8001:RAG Server" \
"8002:MCP Server" "8000:ChromaDB" "8888:SearXNG" \
"8181:Kiwix" "3001:Gitea" "2222:Gitea SSH" \
"9090:InvokeAI" "9000:Portainer" "9443:Portainer S"; do
port="${port_comment%%:*}"
comment="${port_comment##*:}"
sudo ufw allow from "$LAN_SUBNET" to any port "$port" proto tcp \
comment "$comment" > /dev/null
done
sudo ufw reload > /dev/null
ok "UFW rules set for $LAN_SUBNET"
touch "$BASE/.ufw-done"
else
info "UFW rules already set (--force to redo)"
fi
else
warn "ufw not found — skipping firewall config"
fi
# =============================================================================
section "Helper Scripts"
# =============================================================================
# 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_SEARXNG && _START_URLS+=$'echo " SearXNG → http://'"$LOCAL_IP"$':8888"\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 $(hostname).local:3000 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
invokeai.yourdomain.com { reverse_proxy $(hostname).local:9090 }
search.yourdomain.com { reverse_proxy $(hostname).local:8888 }
git.yourdomain.com { reverse_proxy $(hostname).local:3001 }
kiwix.yourdomain.com { reverse_proxy $(hostname).local:8181 }
rag.yourdomain.com { reverse_proxy $(hostname).local:8001 }
mcp.yourdomain.com { reverse_proxy $(hostname).local:8002 }
portainer.yourdomain.com {
reverse_proxy https://$(hostname).local:9443 {
transport http { tls_insecure_skip_verify }
}
}
CADDY
# =============================================================================
section "Systemd Auto-Start"
# =============================================================================
sudo tee /etc/systemd/system/local-ai.service > /dev/null << SYSD
[Unit]
Description=Local AI Stack
After=docker.service network-online.target
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
User=$USER
WorkingDirectory=$BASE
ExecStart=/bin/bash $BASE/start.sh
ExecStop=/bin/bash $BASE/stop.sh
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
SYSD
sudo systemctl daemon-reload
sudo systemctl enable local-ai.service
ok "Systemd service enabled (local-ai.service)"
# =============================================================================
section "Starting Stack"
# =============================================================================
cd "$BASE"
info "Pulling images (this takes a few minutes on first run)..."
COMPOSE_SERVICES=""
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_SEARXNG && COMPOSE_SERVICES+=" searxng"
$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_SEARXNG && echo -e " ${CYAN}SearXNG${NC} → http://$LOCAL_IP:8888"
$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 ""