- laptop_full_setup.sh now handles both fresh install and updates cleanly (detects existing install, --force flag to overwrite config files) - server.py: standalone RAG server with AST-aware code chunking (Python), pattern-split for JS/TS/Go, /ingest/repo and /webhook/gitea|github endpoints - mcp_server.py: new MCP server (port 8002/SSE) with Claude Code-equivalent tools: bash, file ops, ripgrep search, git ops, Gitea API, GitHub API, RAG repo ingest - docker-compose: adds mcp-server service, workspace volume, env_file for tokens - .env preserved on update (tokens never overwritten) - GPU: OLLAMA_NUM_GPU=999 auto-adapts to any VRAM size (no hard-coded 6GB) - ZIM downloads remain in kiwix_download.sh (separate, large files) https://claude.ai/code/session_012gDnantBmFTWZGCiKyjazx
621 lines
22 KiB
Bash
Executable File
621 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# Local AI Stack — Ubuntu 24.04
|
|
# Services: Ollama · Open WebUI · RAG · MCP · ChromaDB · SearXNG
|
|
# Kiwix · Gitea · InvokeAI · Portainer
|
|
#
|
|
# Usage:
|
|
# ./laptop_full_setup.sh — new install or update
|
|
# ./laptop_full_setup.sh --force — overwrite existing config files too
|
|
#
|
|
# ZIM downloads: run ./kiwix_download.sh separately (large files)
|
|
# 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 pulled on first install (adjust to your VRAM)
|
|
EMBED_MODEL="nomic-embed-text" # always needed for RAG
|
|
CHAT_MODEL="qwen2.5:14b"
|
|
CODE_MODEL="qwen2.5-coder:7b"
|
|
FAST_MODEL="qwen2.5:7b"
|
|
|
|
# ── new vs update ─────────────────────────────────────────────────────────────
|
|
IS_UPDATE=false
|
|
[[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true
|
|
|
|
section "Local AI Stack — $($IS_UPDATE && echo UPDATE || echo NEW INSTALL)"
|
|
info "Base : $BASE"
|
|
info "Host : $LOCAL_IP"
|
|
$IS_UPDATE && warn "Existing install found. Config files kept unless --force is passed."
|
|
|
|
# ── 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
|
|
}
|
|
|
|
# =============================================================================
|
|
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..."
|
|
distribution=$(. /etc/os-release && echo "$ID$VERSION_ID")
|
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
|
| sudo gpg --dearmor \
|
|
-o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
|
curl -fsSL \
|
|
"https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.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 \
|
|
kiwix gitea portainer-data logs; do
|
|
mkdir -p "$BASE/$d"
|
|
done
|
|
ok "Directories ready under $BASE"
|
|
|
|
# =============================================================================
|
|
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"
|
|
|
|
# =============================================================================
|
|
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
|
|
|
|
# =============================================================================
|
|
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"
|
|
# =============================================================================
|
|
# 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-models:/root/.ollama
|
|
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
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8000/api/v2/heartbeat || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
# ── 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_healthy
|
|
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 ─────────────────────────────────────────
|
|
# Download ZIMs first: ./kiwix_download.sh
|
|
kiwix:
|
|
image: ghcr.io/kiwix/kiwix-serve:latest
|
|
container_name: kiwix
|
|
restart: unless-stopped
|
|
ports:
|
|
- "0.0.0.0:8181:8080"
|
|
volumes:
|
|
- $BASE/kiwix:/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-models:
|
|
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
|
|
read -rp " LAN subnet for firewall [192.168.1.0/24]: " LAN_SUBNET
|
|
LAN_SUBNET="${LAN_SUBNET:-192.168.1.0/24}"
|
|
[[ "$LAN_SUBNET" =~ /[0-9]+$ ]] || LAN_SUBNET="${LAN_SUBNET}/24"
|
|
|
|
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)..."
|
|
docker compose pull --quiet
|
|
docker compose up -d
|
|
ok "Stack started"
|
|
|
|
if ! $IS_UPDATE; then
|
|
echo ""
|
|
read -rp "Pull Ollama models now? (~20GB total, takes 10-40 min) [Y/n]: " DO_PULL
|
|
if [[ "${DO_PULL,,}" != "n" ]]; then
|
|
bash "$BASE/pull-models.sh"
|
|
else
|
|
info "Run later: bash $BASE/pull-models.sh"
|
|
fi
|
|
fi
|
|
|
|
# =============================================================================
|
|
echo ""
|
|
echo -e "${GREEN}${BOLD}━━━ Done! ━━━${NC}"
|
|
echo ""
|
|
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}Kiwix${NC} → http://$LOCAL_IP:8181 (needs ZIMs — run kiwix_download.sh)"
|
|
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"
|
|
echo ""
|
|
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/"
|
|
echo -e " ${YELLOW}ZIM downloads:${NC} ./kiwix_download.sh"
|
|
echo ""
|
|
$IS_UPDATE && echo -e " ${GREEN}Update complete.${NC}" \
|
|
|| echo -e " ${GREEN}Fresh install complete.${NC}"
|
|
echo ""
|