Files
local-ai/laptop_full_setup.sh
T
2026-03-19 22:25:26 -04:00

840 lines
32 KiB
Bash

#!/bin/bash
# =============================================================================
# Laptop AI Stack — Ubuntu 24.04 + RTX 3000 Mobile (6GB VRAM / 32GB RAM)
# All files live under ~/docker/ai-stack/
# Usage: bash laptop-ai-setup.sh
# =============================================================================
set -euo pipefail
G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; N='\033[0m'
ok() { echo -e "${G}[OK]${N} $1"; }
inf() { echo -e "${C}[..]${N} $1"; }
wrn() { echo -e "${Y}[!!]${N} $1"; }
sec() { echo -e "\n${C}══════════════════════════════════════════════════${N}";
echo -e "${C} $1${N}";
echo -e "${C}══════════════════════════════════════════════════${N}"; }
# All paths relative to this base — no exceptions
BASE="$HOME/docker/ai-stack"
# Get the primary non-Docker, non-loopback IP
LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' || hostname -I | awk '{print $1}')
if [[ -z "$LOCAL_IP" ]]; then
read -rp " Could not detect IP. Enter your LAN IP manually: " LOCAL_IP
fi
# =============================================================================
sec "STEP 1 — Configuration"
# =============================================================================
echo ""
echo " Your hardware: RTX 3000 Mobile (6GB VRAM) + 32GB RAM"
echo " All files will be stored under: $BASE"
echo ""
echo " Model options:"
echo " [1] Smart — 7B + 14B only (fast, recommended)"
echo " 7B: 30-50 tok/s — instant responses"
echo " 14B: 10-20 tok/s — ~20 sec typical response"
echo ""
echo " [2] Full — 7B + 14B + 32B (adds slow but powerful model)"
echo " 32B: 3-5 tok/s — 1-4 min typical response"
echo " Good for: deep research — ask and walk away"
echo ""
read -rp " Model choice [1]: " MODEL_CHOICE; MODEL_CHOICE="${MODEL_CHOICE:-1}"
USE_32B=false
[[ "$MODEL_CHOICE" == "2" ]] && USE_32B=true
echo ""
echo ""
echo " LAN subnet for firewall access — common options:"
echo " 192.168.1.0/24 — only your 192.168.1.x devices (most secure)"
echo " 192.168.0.0/16 — any 192.168.x.x device (covers subnet changes)"
echo " 10.0.0.0/8 — if your network uses 10.x.x.x addresses"
read -rp " Your LAN subnet [192.168.1.0/24]: " LAN_SUBNET
LAN_SUBNET="${LAN_SUBNET:-192.168.1.0/24}"
# Validate format — must end in /number
if [[ ! "$LAN_SUBNET" =~ /[0-9]+$ ]]; then
wrn "Subnet missing CIDR mask — appending /24"
LAN_SUBNET="${LAN_SUBNET}/24"
fi
echo ""
echo -e " ${C}─── Directory Layout ─────────────────────────────${N}"
echo " $BASE/"
echo " ├── docker-compose.yml"
echo " ├── server.py"
echo " ├── requirements.txt"
echo " ├── papers/ ← drop PDFs here to auto-index"
echo " ├── repos/ ← git repos for code RAG"
echo " ├── index/ ← ChromaDB index files"
echo " ├── searxng/ ← SearXNG config"
echo " ├── invokeai-outputs/ ← generated images"
echo " └── logs/ ← service logs"
echo ""
read -rp " Proceed? (Y/n): " CONFIRM
[[ "${CONFIRM,,}" == "n" ]] && exit 0
# =============================================================================
sec "STEP 2 — Prerequisites"
# =============================================================================
inf "Checking Docker..."
if ! command -v docker &>/dev/null; then
inf "Installing Docker..."
sudo apt-get update -qq
sudo apt-get install -y curl
curl -fsSL https://get.docker.com | sudo bash
sudo usermod -aG docker "$USER"
ok "Docker installed — you may need to log out/in for group to take effect"
wrn "If docker commands fail, run: newgrp docker"
else
ok "Docker present: $(docker --version)"
fi
inf "Checking NVIDIA Container Toolkit..."
if ! dpkg -l | grep -q nvidia-container-toolkit 2>/dev/null; then
inf "Installing NVIDIA Container Toolkit..."
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L 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 present"
fi
# =============================================================================
sec "STEP 3 — Directory Structure"
# =============================================================================
mkdir -p "$BASE"/{papers,repos,index,searxng,invokeai-outputs,logs,kiwix,gitea}
ok "Directories created:"
ok " $BASE/papers"
ok " $BASE/repos"
ok " $BASE/index"
ok " $BASE/searxng"
ok " $BASE/invokeai-outputs"
ok " $BASE/logs"
# =============================================================================
sec "STEP 4 — RAG Server Files"
# =============================================================================
inf "Writing $BASE/server.py..."
cat > "$BASE/server.py" << 'PYTHON_SERVER_EOF'
#!/usr/bin/env python3
"""
Local RAG Server — Complete Standalone Script
Drop PDFs into the papers folder and they auto-index.
OpenAI-compatible /v1/chat/completions API.
"""
import os, sys, json, time, hashlib, logging, asyncio, threading
from pathlib import Path
from typing import Optional
try:
import chromadb
import httpx
from fastapi import FastAPI, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
except ImportError as e:
print(f"[ERROR] Missing dependency: {e}")
sys.exit(1)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("rag")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
CHROMA_URL = os.getenv("CHROMA_URL", "http://chromadb:8000")
EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text")
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen2.5:14b")
PAPERS_DIR = Path(os.getenv("PAPERS_DIR", "/papers"))
REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos"))
INDEX_DIR = Path(os.getenv("INDEX_DIR", "/index"))
INDEX_FILE = INDEX_DIR / "indexed.json"
PORT = int(os.getenv("PORT", "8001"))
PAPER_EXTENSIONS = {".pdf",".txt",".md",".docx",".rst"}
CODE_EXTENSIONS = {".py",".js",".ts",".html",".css",".json",".sh",".yaml",".sql"}
SKIP_DIRS = {"node_modules",".git","__pycache__",".venv","dist","build"}
for d in [PAPERS_DIR, REPOS_DIR, INDEX_DIR]:
d.mkdir(parents=True, exist_ok=True)
chroma_client = papers_db = code_db = embeddings = None
_index_lock = threading.Lock()
def init_chroma():
host = CHROMA_URL.replace("http://","").replace("https://","").split(":")[0]
port = int(CHROMA_URL.split(":")[-1])
for attempt in range(15):
try:
c = chromadb.HttpClient(host=host, port=port)
c.heartbeat()
log.info("Connected to ChromaDB")
return c
except Exception as e:
log.warning(f"ChromaDB not ready ({attempt+1}/15): {e}")
time.sleep(3)
sys.exit(1)
def setup_databases():
global chroma_client, papers_db, code_db, embeddings
chroma_client = init_chroma()
embeddings = OllamaEmbeddings(model=EMBED_MODEL, base_url=OLLAMA_URL)
papers_db = Chroma(client=chroma_client, collection_name="papers", embedding_function=embeddings)
code_db = Chroma(client=chroma_client, collection_name="code", embedding_function=embeddings)
log.info("Vector stores ready")
def load_index():
with _index_lock:
if INDEX_FILE.exists():
try: return json.loads(INDEX_FILE.read_text())
except: return {}
return {}
def save_index(idx):
with _index_lock:
INDEX_FILE.write_text(json.dumps(idx, indent=2))
def file_hash(path):
try: return hashlib.md5(path.read_bytes()).hexdigest()
except: return ""
def ingest_paper(path):
idx = load_index(); h = file_hash(path); key = str(path)
if idx.get(key) == h: return
log.info(f"[papers] Ingesting: {path.name}")
try:
loader = PyPDFLoader(str(path)) if path.suffix == ".pdf" \
else TextLoader(str(path), encoding="utf-8")
pages = loader.load()
except Exception as e:
log.warning(f"Could not load {path.name}: {e}"); return
s_spl = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200)
d_spl = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=80)
s_docs = s_spl.split_documents(pages)
d_docs = d_spl.split_documents(pages)
for i,doc in enumerate(s_docs): doc.metadata.update({"layer":"summary","source_file":path.name})
for i,doc in enumerate(d_docs): doc.metadata.update({"layer":"detail", "source_file":path.name})
papers_db.add_documents(s_docs + d_docs)
log.info(f"[papers] {path.name}: {len(s_docs)} summary + {len(d_docs)} detail chunks")
idx[key] = h; save_index(idx)
def ingest_papers_dir():
for ext in PAPER_EXTENSIONS:
for f in PAPERS_DIR.rglob(f"*{ext}"): ingest_paper(f)
def retrieve(query, k=6):
results = []
if papers_db:
try: results.extend(papers_db.similarity_search(query, k=4))
except: pass
if code_db:
try: results.extend(code_db.similarity_search(query, k=3))
except: pass
seen = set(); unique = []
for doc in results:
h = hashlib.md5(doc.page_content.encode()).hexdigest()
if h not in seen: seen.add(h); unique.append(doc)
return unique[:k*2]
app = FastAPI(title="Local RAG Server")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class Message(BaseModel):
role: str; content: str
class ChatRequest(BaseModel):
model: Optional[str] = None
messages: list[Message]
stream: Optional[bool] = False
top_k: Optional[int] = 6
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest):
user_msg = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
if not user_msg.strip(): raise HTTPException(400, "No user message")
chunks = retrieve(user_msg, req.top_k or 6)
parts = []; sources = []
for chunk in chunks:
fname = chunk.metadata.get("source_file","unknown")
parts.append(f"[{fname}]\n{chunk.page_content.strip()}")
if fname not in sources: sources.append(fname)
context = "\n\n---\n\n".join(parts)
source_list = "\n".join(f" • {s}" for s in sources)
sys_content = f"You are a research assistant.\n\n=== CONTEXT ===\n{context}\n\n=== SOURCES ===\n{source_list}" \
if context.strip() else "You are a helpful assistant. No relevant documents found."
ollama_messages = [{"role":"system","content":sys_content}] + \
[{"role":m.role,"content":m.content} for m in req.messages]
model = req.model or CHAT_MODEL
if req.stream:
async def stream_gen():
async with httpx.AsyncClient(timeout=600) as client:
async with client.stream("POST", f"{OLLAMA_URL}/api/chat",
json={"model":model,"messages":ollama_messages,"stream":True}) as resp:
async for line in resp.aiter_lines():
if not line.strip(): continue
try:
token = json.loads(line).get("message",{}).get("content","")
if token:
yield f"data: {json.dumps({'object':'chat.completion.chunk','choices':[{'index':0,'delta':{'content':token}}]})}\n\n"
except: pass
yield "data: [DONE]\n\n"
return StreamingResponse(stream_gen(), media_type="text/event-stream")
async with httpx.AsyncClient(timeout=600) as client:
resp = await client.post(f"{OLLAMA_URL}/api/chat",
json={"model":model,"messages":ollama_messages,"stream":False})
resp.raise_for_status()
reply = resp.json().get("message",{}).get("content","")
return {"object":"chat.completion","model":model,
"choices":[{"index":0,"message":{"role":"assistant","content":reply}}]}
@app.get("/health")
async def health():
pc = papers_db._collection.count() if papers_db else 0
cc = code_db._collection.count() if code_db else 0
return {"status":"ok","papers_chunks":pc,"code_chunks":cc,
"chat_model":CHAT_MODEL,"embed_model":EMBED_MODEL}
@app.post("/ingest/papers")
async def trigger_ingest(background: BackgroundTasks):
background.add_task(ingest_papers_dir)
return {"status":"paper ingestion queued"}
class PaperDropHandler(FileSystemEventHandler):
def _handle(self, path_str):
path = Path(path_str)
if path.suffix.lower() in PAPER_EXTENSIONS and path.is_file():
time.sleep(0.5); ingest_paper(path)
def on_created(self, event):
if not event.is_directory: self._handle(event.src_path)
@app.on_event("startup")
async def on_startup():
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, setup_databases)
observer = Observer()
observer.schedule(PaperDropHandler(), str(PAPERS_DIR), recursive=True)
observer.start()
def initial_index():
time.sleep(2); ingest_papers_dir()
log.info("Initial scan complete")
threading.Thread(target=initial_index, daemon=True).start()
log.info(f"RAG server ready on port {PORT}")
if __name__ == "__main__":
import uvicorn
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=False)
PYTHON_SERVER_EOF
ok "server.py written"
cat > "$BASE/requirements.txt" << 'REQ'
fastapi
uvicorn[standard]
httpx
pydantic
chromadb
langchain
langchain-community
langchain-chroma
langchain-ollama
langchain-text-splitters
pypdf
unstructured
watchdog
python-multipart
REQ
ok "requirements.txt written"
# =============================================================================
sec "STEP 5 — SearXNG Config"
# =============================================================================
cat > "$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
ok "SearXNG config written"
# =============================================================================
sec "STEP 6 — Docker Compose"
# =============================================================================
# Write .env file so BASE path is available in compose
cat > "$BASE/.env" << ENV
BASE=$BASE
ENV
cat > "$BASE/docker-compose.yml" << COMPOSE
# =============================================================================
# Laptop AI Stack — RTX 3000 Mobile (6GB) + 32GB RAM
# Base directory: $BASE
# Generated: $(date)
#
# Directory layout on host:
# $BASE/papers/ → RAG documents (drop PDFs here)
# $BASE/repos/ → Git repos for code RAG
# $BASE/index/ → ChromaDB index
# $BASE/searxng/ → SearXNG config
# $BASE/invokeai-outputs/ → Generated images
# $BASE/logs/ → Service logs
# =============================================================================
services:
# ── Ollama — Local 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_CTX=16384
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_MAX_LOADED_MODELS=1
- OLLAMA_NUM_GPU=999
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
- OPENAI_API_KEY=local-rag-key
- 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
# ── 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
- invokeai-db:/invokeai/databases
environment:
- INVOKEAI_HOST=0.0.0.0
- INVOKEAI_PORT=9090
- INVOKEAI_PRECISION=float16
- INVOKEAI_VRAM_CACHE_SIZE=3
- INVOKEAI_RAM_CACHE_SIZE=12
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# ── ChromaDB — Vector database ────────────────────────────────────────────
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 ────────────────────────────────────────────────────────────
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
- INDEX_DIR=/index
- PORT=8001
command: >
bash -c "apt-get update -qq &&
apt-get install -y --no-install-recommends libmagic1 poppler-utils &&
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
# ── 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 ─────────────────────────────────────────────
# Download ZIM file first:
# cd ~/docker/ai-stack/kiwix
# wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2024-10.zim
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__server__ROOT_URL=https://git.yourdomain.com
- GITEA__server__SSH_DOMAIN=git.yourdomain.com
- GITEA__server__DOMAIN=git.yourdomain.com
- GITEA__webhook__ALLOWED_HOST_LIST=rag-server
# ── 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
- portainer-data:/data
# Named volumes for data that doesn't need to be directly browsable on host
# (models, webui state, invokeai db) — everything else maps to $BASE subfolders
volumes:
ollama-models:
open-webui-data:
invokeai-models:
invokeai-db:
portainer-data:
COMPOSE
ok "docker-compose.yml written → $BASE/docker-compose.yml"
# =============================================================================
sec "STEP 7 — UFW Rules"
# =============================================================================
sudo ufw allow from "$LAN_SUBNET" to any port 9090 proto tcp comment "InvokeAI" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 3000 proto tcp comment "Open WebUI" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 8001 proto tcp comment "RAG Server" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 8888 proto tcp comment "SearXNG" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 11434 proto tcp comment "Ollama" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 8000 proto tcp comment "ChromaDB" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 9000 proto tcp comment "Portainer" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 9443 proto tcp comment "Portainer S" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 8181 proto tcp comment "Kiwix" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 3001 proto tcp comment "Gitea" > /dev/null
sudo ufw allow from "$LAN_SUBNET" to any port 2222 proto tcp comment "Gitea SSH" > /dev/null
sudo ufw reload > /dev/null
ok "UFW rules set for $LAN_SUBNET"
# Fix Docker bypassing UFW on Ubuntu 24.04
DAEMON_FILE="/etc/docker/daemon.json"
if ! grep -q "iptables" "$DAEMON_FILE" 2>/dev/null; then
inf "Applying Docker UFW fix..."
if [[ -f "$DAEMON_FILE" ]]; then
sudo python3 -c "
import json
with open('$DAEMON_FILE') as f: d = json.load(f)
d['iptables'] = True
with open('$DAEMON_FILE','w') as f: json.dump(d, f, indent=2)
"
else
echo '{"iptables": true}' | sudo tee "$DAEMON_FILE" > /dev/null
fi
sudo systemctl restart docker
ok "Docker UFW fix applied"
fi
# =============================================================================
sec "STEP 8 — Helper Scripts"
# =============================================================================
# ── pull-models.sh ────────────────────────────────────────────────────────────
cat > "$BASE/pull-models.sh" << PULL
#!/bin/bash
echo "Waiting for Ollama..."
until docker exec ollama ollama list &>/dev/null; do sleep 2; done
echo "Ollama ready."
echo ""
echo "1/4 — Embedding model (required for RAG, ~274MB)..."
docker exec ollama ollama pull nomic-embed-text
echo "2/4 — Qwen 7B: fast everyday model, fits 100% in 6GB GPU (~5GB)..."
docker exec ollama ollama pull qwen2.5:7b
echo "3/4 — Qwen 14B: smarter, partial GPU offload (~9GB)..."
docker exec ollama ollama pull qwen2.5:14b
echo "4/4 — Qwen Coder 7B: code + scripts, fits in GPU (~5GB)..."
docker exec ollama ollama pull qwen2.5-coder:7b
$([ "$USE_32B" = true ] && cat << 'PULL32B'
echo ""
echo "32B model selected — 19GB, slow on 6GB GPU (1-4 min responses)"
read -rp "Pull 32B now? (y/N): " DO32B
if [[ "\${DO32B,,}" == "y" ]]; then
echo "Pulling Qwen 32B (~19GB — takes 20-40 min)..."
docker exec ollama ollama pull qwen2.5:32b
echo "32B pulled."
fi
PULL32B
)
echo ""
echo "Models ready. Speed guide on your RTX 3000 Mobile:"
echo " nomic-embed-text — automatic (RAG embeddings only)"
echo " qwen2.5:7b — 30-50 tok/s — chat, image prompts, quick Q&A"
echo " qwen2.5:14b — 10-20 tok/s — RAG, analysis, writing"
echo " qwen2.5-coder:7b — 30-50 tok/s — code, scripts"
$([ "$USE_32B" = true ] && echo "echo \" qwen2.5:32b — 3-5 tok/s — deep research (slow)\"")
PULL
chmod +x "$BASE/pull-models.sh"
# ── start.sh ──────────────────────────────────────────────────────────────────
cat > "$BASE/start.sh" << STARTSH
#!/bin/bash
cd "$BASE"
docker compose up -d
echo ""
echo " InvokeAI → http://$LOCAL_IP:9090"
echo " Open WebUI → http://$LOCAL_IP:3000"
echo " SearXNG → http://$LOCAL_IP:8080"
echo " RAG Health → http://$LOCAL_IP:8001/health"
echo " Portainer → https://$LOCAL_IP:9443"
echo ""
echo " Generated images → $BASE/invokeai-outputs/"
echo " Drop PDFs here → $BASE/papers/"
echo ""
echo " First run? Pull models: bash $BASE/pull-models.sh"
echo ""
echo " InvokeAI tip: upload jesus.safetensors via"
echo " Model Manager → LoRA → Import"
STARTSH
chmod +x "$BASE/start.sh"
# ── stop.sh ───────────────────────────────────────────────────────────────────
cat > "$BASE/stop.sh" << STOPSH
#!/bin/bash
cd "$BASE"
docker compose down
echo "All services stopped."
STOPSH
chmod +x "$BASE/stop.sh"
# ── status.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 ""
echo "=== Containers ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo "=== Ollama models loaded ==="
docker exec ollama ollama ps 2>/dev/null || echo " (not running)"
echo ""
echo "=== RAG health ==="
curl -s http://localhost:8001/health 2>/dev/null | python3 -m json.tool || echo " (not running)"
echo ""
echo "=== Disk usage ==="
du -sh "$BASE"/*/ 2>/dev/null
STATUSSH
chmod +x "$BASE/status.sh"
# ── Caddyfile ─────────────────────────────────────────────────────────────────
cat > "$BASE/Caddyfile.example" << CADDY
# =============================================================
# Caddy2 reverse proxy config
#
# RECOMMENDED: Use hostname instead of IP so it never breaks
# when your laptop's DHCP address changes.
# Your laptop hostname is: $(hostname)
# With mDNS it's reachable as: $(hostname).local
#
# OR set a static DHCP reservation in your router:
# tie your MAC address to a fixed IP like $LOCAL_IP
# =============================================================
# Option A — use hostname (works on most home networks, never breaks)
invokeai.yourdomain.com { reverse_proxy $(hostname).local:9090 }
webui.yourdomain.com { reverse_proxy $(hostname).local:3000 }
search.yourdomain.com { reverse_proxy $(hostname).local:8080 }
rag.yourdomain.com { reverse_proxy $(hostname).local:8001 }
portainer.yourdomain.com {
reverse_proxy https://$(hostname).local:9443 {
transport http { tls_insecure_skip_verify }
}
}
# Option B — use IP (simpler but breaks if DHCP reassigns)
# invokeai.yourdomain.com { reverse_proxy $LOCAL_IP:9090 }
# webui.yourdomain.com { reverse_proxy $LOCAL_IP:3000 }
# search.yourdomain.com { reverse_proxy $LOCAL_IP:8080 }
# rag.yourdomain.com { reverse_proxy $LOCAL_IP:8001 }
# portainer.yourdomain.com {
# reverse_proxy https://$LOCAL_IP:9443 {
# transport http { tls_insecure_skip_verify }
# }
# }
CADDY
# =============================================================================
sec "STEP 9 — Systemd Auto-Start"
# =============================================================================
sudo bash -c "cat > /etc/systemd/system/laptop-ai.service << SYSD
[Unit]
Description=Laptop AI Stack (InvokeAI + Ollama + Open WebUI + RAG)
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 laptop-ai.service
ok "Systemd auto-start enabled (laptop-ai.service)"
# =============================================================================
echo ""
echo -e "${G}════════════════════════════════════════════════════${N}"
echo -e "${G} Setup complete!${N}"
echo -e "${G}════════════════════════════════════════════════════${N}"
echo ""
echo -e " ${C}Everything lives under:${N} $BASE"
echo ""
echo -e " ${C}NEXT STEPS${N}"
echo " ──────────────────────────────────────────────────"
echo " 1. Start the stack:"
echo " bash $BASE/start.sh"
echo ""
echo " 2. Pull AI models (10-40 min):"
echo " bash $BASE/pull-models.sh"
echo ""
echo " 3. Upload jesus.safetensors to InvokeAI:"
echo " Open http://$LOCAL_IP:9090"
echo " → Model Manager → LoRA → Import"
echo ""
echo " 4. Drop PDFs into papers folder to auto-index:"
echo " $BASE/papers/"
echo ""
echo " 5. Generated images saved to:"
echo " $BASE/invokeai-outputs/"
echo ""
echo " 6. Add to Caddy2 on your proxy machine:"
echo " cat $BASE/Caddyfile.example"
echo ""
echo -e " ${C}SERVICE URLS${N}"
echo " ──────────────────────────────────────────────────"
echo " InvokeAI → http://$LOCAL_IP:9090"
echo " Open WebUI → http://$LOCAL_IP:3000"
echo " SearXNG → http://$LOCAL_IP:8080"
echo " RAG Health → http://$LOCAL_IP:8001/health"
echo " Portainer → https://$LOCAL_IP:9443"
echo ""
echo -e " ${Y}NOTE: If docker commands fail, run: newgrp docker${N}"
echo -e " ${Y} or log out and back in.${N}"