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