Add files via upload

This commit is contained in:
Outis
2026-03-19 22:25:26 -04:00
committed by GitHub
commit e9bee0f13d
3 changed files with 1278 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
#!/bin/bash
# =============================================================================
# Kiwix ZIM Download Script
# Downloads all ZIM files to ~/docker/ai-stack/kiwix/
# Finds latest version of each file automatically
# Usage: bash kiwix-download.sh
# =============================================================================
KIWIX_DIR="$HOME/docker/ai-stack/kiwix"
MIRROR="https://ftp.fau.de/kiwix/zim"
MIRROR2="https://download.kiwix.org/zim" # fallback for files missing on fau.de
LOG="$HOME/docker/ai-stack/logs/kiwix-download.log"
mkdir -p "$KIWIX_DIR" "$(dirname "$LOG")"
G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; N='\033[0m'
ok() { echo -e "${G}[OK]${N} $1" | tee -a "$LOG"; }
inf() { echo -e "${C}[..]${N} $1" | tee -a "$LOG"; }
wrn() { echo -e "${Y}[!!]${N} $1" | tee -a "$LOG"; }
# Find latest version of a ZIM file from mirror
# Usage: latest_zim "base_url" "pattern"
latest_zim() {
local base_url="$1"
local pattern="$2"
curl -s "$base_url/" | \
grep -oP "${pattern}_[\d-]+\.zim" | \
sort -u | tail -1
}
# Download a ZIM file if not already present
# Usage: download_zim "category" "filename" "description" "size" ["mirror"]
download_zim() {
local category="$1"
local filename="$2"
local description="$3"
local size="$4"
local mirror="${5:-$MIRROR}"
local url="$mirror/$category/$filename"
local dest="$KIWIX_DIR/$filename"
if [[ -f "$dest" ]]; then
ok "$description already downloaded — skipping"
return
fi
inf "Queuing: $description (~$size)"
inf " URL: $url"
nohup wget -c "$url" -O "$dest" \
>> "$LOG" 2>&1 &
echo $! >> "$KIWIX_DIR/.download_pids"
ok "Started download PID $!$description"
}
echo "" | tee -a "$LOG"
echo "=== Kiwix Download Started: $(date) ===" | tee -a "$LOG"
echo "" | tee -a "$LOG"
echo -e "${C}Finding latest versions...${N}"
echo ""
# ── Find latest filenames ──────────────────────────────────────────────────
inf "Checking Wikipedia..."
WIKI=$(latest_zim "$MIRROR/wikipedia" "wikipedia_en_all_nopic")
inf "Checking Wiktionary..."
WIKT=$(latest_zim "$MIRROR/wiktionary" "wiktionary_en_all_nopic")
inf "Checking Wikiquote..."
WIKQ=$(latest_zim "$MIRROR/wikiquote" "wikiquote_en_all_nopic")
inf "Checking Wikisource..."
WIKS=$(latest_zim "$MIRROR/wikisource" "wikisource_en_all_nopic")
inf "Checking Wikibooks..."
WIKB=$(latest_zim "$MIRROR/wikibooks" "wikibooks_en_all_nopic")
inf "Checking Wikivoyage..."
WIKV=$(latest_zim "$MIRROR/wikivoyage" "wikivoyage_en_all_nopic")
inf "Checking Wikiversity..."
WIKUNI=$(latest_zim "$MIRROR/wikiversity" "wikiversity_en_all_nopic")
inf "Checking WikiNews..."
WIKNEWS=$(latest_zim "$MIRROR/wikinews" "wikinews_en_all_nopic")
inf "Checking Vikidia (kids K-8)..."
VIKIDIA=$(latest_zim "$MIRROR/vikidia" "vikidia_en_all_nopic")
# Stack Overflow: domain-style filename in stack_exchange/
inf "Checking Stack Overflow..."
SO=$(latest_zim "$MIRROR/stack_exchange" "stackoverflow.com_en_all")
# Arch Wiki: _maxi is part of the base name, not the date stamp
inf "Checking Arch Wiki..."
ARCH=$(latest_zim "$MIRROR/other" "archlinux_en_all_maxi")
# TED: lives in ted/ folder, mul_youth variant
inf "Checking TED Talks..."
TED=$(latest_zim "$MIRROR/ted" "ted_mul_youth")
# PhET: own folder
inf "Checking PhET Simulations..."
PHET=$(latest_zim "$MIRROR/phet" "phet_en_all")
# DevDocs: variant is zig not all
inf "Checking DevDocs..."
DEVDOCS=$(latest_zim "$MIRROR/devdocs" "devdocs_en_zig")
inf "Checking FreeCodeCamp..."
FCC=$(latest_zim "$MIRROR/freecodecamp" "freecodecamp_en_all")
inf "Checking iFixit..."
IFIX=$(latest_zim "$MIRROR/ifixit" "ifixit_en_all")
# LibreTexts: own folder, workforce variant (no _all on this mirror)
inf "Checking LibreTexts..."
LIBRE=$(latest_zim "$MIRROR/libretexts" "libretexts.org_en_workforce")
# Gutenberg: en_all exists on download.kiwix.org, not ftp.fau.de
inf "Checking Project Gutenberg..."
GUT=$(latest_zim "$MIRROR2/gutenberg" "gutenberg_en_all")
echo ""
echo -e "${C}═══════════════════════════════════════════════════${N}"
echo -e "${C} Download Queue${N}"
echo -e "${C}═══════════════════════════════════════════════════${N}"
echo ""
echo " Wikipedia (no images) ${WIKI:-NOT FOUND} ~46GB"
echo " Wiktionary ${WIKT:-NOT FOUND} ~2GB"
echo " Wikiquote ${WIKQ:-NOT FOUND} ~300MB"
echo " Wikisource ${WIKS:-NOT FOUND} ~4GB"
echo " Wikibooks ${WIKB:-NOT FOUND} ~500MB"
echo " Wikivoyage ${WIKV:-NOT FOUND} ~200MB"
echo " Wikiversity ${WIKUNI:-NOT FOUND} ~500MB"
echo " WikiNews ${WIKNEWS:-NOT FOUND} ~300MB"
echo " Vikidia (kids K-8) ${VIKIDIA:-NOT FOUND} ~66MB"
echo " Stack Overflow ${SO:-NOT FOUND} ~3GB"
echo " Arch Linux Wiki ${ARCH:-NOT FOUND} ~30MB"
echo " TED Talks ${TED:-NOT FOUND} ~5GB"
echo " PhET Simulations ${PHET:-NOT FOUND} ~500MB"
echo " DevDocs ${DEVDOCS:-NOT FOUND} ~1GB"
echo " FreeCodeCamp ${FCC:-NOT FOUND} ~small"
echo " iFixit (repair guides) ${IFIX:-NOT FOUND} ~2GB"
echo " LibreTexts (textbooks) ${LIBRE:-NOT FOUND} ~varies"
echo " Project Gutenberg ${GUT:-NOT FOUND} ~60GB"
echo ""
echo -e "${Y} Total estimate: ~130GB — make sure you have space!${N}"
echo ""
df -h "$KIWIX_DIR" | tail -1 | awk '{print " Available disk space: " $4}'
echo ""
read -rp " Proceed with all downloads? (Y/n): " CONFIRM
[[ "${CONFIRM,,}" == "n" ]] && exit 0
# ── Start downloads ────────────────────────────────────────────────────────
echo ""
rm -f "$KIWIX_DIR/.download_pids"
[[ -n "$WIKI" ]] && download_zim "wikipedia" "$WIKI" "Wikipedia (no images)" "46GB"
[[ -n "$WIKT" ]] && download_zim "wiktionary" "$WIKT" "Wiktionary" "2GB"
[[ -n "$WIKQ" ]] && download_zim "wikiquote" "$WIKQ" "Wikiquote" "300MB"
[[ -n "$WIKS" ]] && download_zim "wikisource" "$WIKS" "Wikisource" "4GB"
[[ -n "$WIKB" ]] && download_zim "wikibooks" "$WIKB" "Wikibooks" "500MB"
[[ -n "$WIKV" ]] && download_zim "wikivoyage" "$WIKV" "Wikivoyage" "200MB"
[[ -n "$WIKUNI" ]] && download_zim "wikiversity" "$WIKUNI" "Wikiversity" "500MB"
[[ -n "$WIKNEWS" ]] && download_zim "wikinews" "$WIKNEWS" "WikiNews" "300MB"
[[ -n "$VIKIDIA" ]] && download_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" "66MB"
[[ -n "$SO" ]] && download_zim "stack_exchange" "$SO" "Stack Overflow" "3GB"
[[ -n "$ARCH" ]] && download_zim "other" "$ARCH" "Arch Linux Wiki" "30MB"
[[ -n "$TED" ]] && download_zim "ted" "$TED" "TED Talks" "5GB"
[[ -n "$PHET" ]] && download_zim "phet" "$PHET" "PhET Simulations" "500MB"
[[ -n "$DEVDOCS" ]] && download_zim "devdocs" "$DEVDOCS" "DevDocs" "1GB"
[[ -n "$FCC" ]] && download_zim "freecodecamp" "$FCC" "FreeCodeCamp" "small"
[[ -n "$IFIX" ]] && download_zim "ifixit" "$IFIX" "iFixit (repair guides)" "2GB"
[[ -n "$LIBRE" ]] && download_zim "libretexts" "$LIBRE" "LibreTexts (textbooks)" "varies"
[[ -n "$GUT" ]] && download_zim "gutenberg" "$GUT" "Project Gutenberg" "60GB" "$MIRROR2"
echo ""
echo -e "${G}════════════════════════════════════════════════════${N}"
echo -e "${G} All downloads started in background${N}"
echo -e "${G}════════════════════════════════════════════════════${N}"
echo ""
echo " Monitor progress:"
echo " tail -f $LOG"
echo ""
echo " Check file sizes growing:"
echo " watch -n 60 'ls -lh $KIWIX_DIR/'"
echo ""
echo " Check active downloads:"
echo " jobs -l"
echo " ps aux | grep wget"
echo ""
echo " Once all downloads complete, restart Kiwix:"
echo " cd ~/docker/ai-stack"
echo " docker compose up -d kiwix"
echo ""
echo -e "${Y} NOTE: Downloads resume automatically if interrupted (-c flag)${N}"
+839
View File
@@ -0,0 +1,839 @@
#!/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}"
+241
View File
@@ -0,0 +1,241 @@
#!/bin/bash
# =============================================================================
# AI Stack Update Script — Safe to run on existing installation
# Adds: Kiwix (offline Wikipedia) + Gitea (self-hosted git)
# Never overwrites: docker-compose.yml, searxng config, server.py
# Usage: bash update-ai-stack.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}"; }
BASE="$HOME/docker/ai-stack"
COMPOSE="$BASE/docker-compose.yml"
LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' || hostname -I | awk '{print $1}')
# Verify this is an existing installation
if [[ ! -f "$COMPOSE" ]]; then
echo "Error: $COMPOSE not found. Run the main setup script first."
exit 1
fi
echo ""
echo -e " ${C}AI Stack Update${N}"
echo " Base: $BASE"
echo " IP: $LOCAL_IP"
echo ""
read -rp " Proceed? (Y/n): " CONFIRM
[[ "${CONFIRM,,}" == "n" ]] && exit 0
# =============================================================================
sec "STEP 1 — Create new directories"
# =============================================================================
mkdir -p "$BASE"/{kiwix,gitea}
ok "Directories ready"
# =============================================================================
sec "STEP 2 — Add Kiwix to docker-compose.yml"
# =============================================================================
if grep -q "kiwix" "$COMPOSE"; then
ok "Kiwix already in docker-compose.yml — skipping"
else
inf "Adding Kiwix service..."
# Insert before the volumes: section
python3 - << PYEOF
import re
with open('$COMPOSE', 'r') as f:
content = f.read()
kiwix_service = '''
# ── Kiwix — Offline Wikipedia ─────────────────────────────────────────────
# ZIM file goes in: $BASE/kiwix/
# Download: wget -c 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"
'''
# Insert before volumes: section
content = re.sub(r'(\nvolumes:)', kiwix_service + r'\1', content, count=1)
with open('$COMPOSE', 'w') as f:
f.write(content)
PYEOF
ok "Kiwix added to docker-compose.yml"
fi
# =============================================================================
sec "STEP 3 — Add Gitea to docker-compose.yml"
# =============================================================================
if grep -q "gitea" "$COMPOSE"; then
ok "Gitea already in docker-compose.yml — skipping"
else
inf "Adding Gitea service..."
read -rp " Your domain for Gitea (e.g. git.yourdomain.com): " GITEA_DOMAIN
GITEA_DOMAIN="${GITEA_DOMAIN:-git.yourdomain.com}"
python3 - << PYEOF
import re
with open('$COMPOSE', 'r') as f:
content = f.read()
gitea_service = '''
# ── Gitea — Self-hosted Git ────────────────────────────────────────────────
# First run: go to http://$LOCAL_IP:3001 to complete setup
# Mirror GitHub repos: Gitea → Explore → Migrate → GitHub
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://$GITEA_DOMAIN
- GITEA__server__DOMAIN=$GITEA_DOMAIN
- GITEA__server__SSH_DOMAIN=$GITEA_DOMAIN
- GITEA__webhook__ALLOWED_HOST_LIST=rag-server
'''
content = re.sub(r'(\nvolumes:)', gitea_service + r'\1', content, count=1)
with open('$COMPOSE', 'w') as f:
f.write(content)
PYEOF
ok "Gitea added to docker-compose.yml"
fi
# =============================================================================
sec "STEP 4 — Fix SearXNG port in docker-compose.yml"
# =============================================================================
if grep -q "8080:8080" "$COMPOSE"; then
inf "Fixing SearXNG port 8080 → 8888..."
sed -i 's/0\.0\.0\.0:8080:8080/0.0.0.0:8888:8080/g' "$COMPOSE"
ok "SearXNG port fixed"
else
ok "SearXNG port already correct — skipping"
fi
# =============================================================================
sec "STEP 5 — Fix InvokeAI precision fp16 → float16"
# =============================================================================
if grep -q "INVOKEAI_PRECISION=fp16" "$COMPOSE"; then
inf "Fixing InvokeAI precision..."
sed -i 's/INVOKEAI_PRECISION=fp16/INVOKEAI_PRECISION=float16/g' "$COMPOSE"
ok "InvokeAI precision fixed"
else
ok "InvokeAI precision already correct — skipping"
fi
# =============================================================================
sec "STEP 6 — UFW rules for new services"
# =============================================================================
read -rp " Your LAN subnet [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"
# Only add rules that don't exist yet
add_ufw_rule() {
local port=$1 proto=$2 comment=$3
if ! sudo ufw status | grep -q "$port/$proto"; then
sudo ufw allow from "$LAN_SUBNET" to any port "$port" proto "$proto" comment "$comment" > /dev/null
ok "UFW: opened port $port ($comment)"
else
ok "UFW: port $port already open — skipping"
fi
}
add_ufw_rule 8181 tcp "Kiwix"
add_ufw_rule 3001 tcp "Gitea"
add_ufw_rule 2222 tcp "Gitea SSH"
add_ufw_rule 8888 tcp "SearXNG"
sudo ufw reload > /dev/null
ok "UFW updated"
# =============================================================================
sec "STEP 7 — Start new services"
# =============================================================================
cd "$BASE"
inf "Starting Kiwix and Gitea..."
docker compose up -d kiwix gitea
inf "Restarting fixed services (InvokeAI, SearXNG)..."
docker compose up -d invokeai searxng
ok "Services started"
# =============================================================================
sec "STEP 8 — Status check"
# =============================================================================
echo ""
echo "Waiting 10 seconds for services to initialize..."
sleep 10
docker compose ps
# =============================================================================
echo ""
echo -e "${G}════════════════════════════════════════════════════${N}"
echo -e "${G} Update complete!${N}"
echo -e "${G}════════════════════════════════════════════════════${N}"
echo ""
echo -e " ${C}NEW SERVICES${N}"
echo " ──────────────────────────────────────────────────"
echo " Kiwix → http://$LOCAL_IP:8181"
echo " Gitea → http://$LOCAL_IP:3001"
echo ""
echo -e " ${C}FIXED SERVICES${N}"
echo " ──────────────────────────────────────────────────"
echo " InvokeAI → http://$LOCAL_IP:9090 (precision fix)"
echo " SearXNG → http://$LOCAL_IP:8888 (port fix)"
echo ""
echo -e " ${C}NEXT STEPS${N}"
echo " ──────────────────────────────────────────────────"
echo " 1. Set up Gitea at http://$LOCAL_IP:3001"
echo " → Complete install wizard"
echo " → Create admin account"
echo " → Mirror your GitHub repos"
echo ""
echo " 2. Download Wikipedia ZIM (22GB — use torrent for best results):"
echo " cd $BASE/kiwix"
echo " # Via wget:"
echo " wget -c https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2026-01.zim"
echo " # Via torrent (faster + resumes):"
echo " sudo apt install transmission-cli"
echo " transmission-cli https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2026-01.zim.torrent --download-dir $BASE/kiwix"
echo ""
echo " 3. Update Caddy config — add to your Caddyfile:"
echo " wiki.yourdomain.com { reverse_proxy $LOCAL_IP:8181 }"
echo " git.yourdomain.com { reverse_proxy $LOCAL_IP:3001 }"
echo ""
echo " 4. Update Open WebUI SearXNG URL to:"
echo " http://searxng:8080/search?q=<query>&format=json"
echo ""
echo " 5. Set up Gitea webhook for RAG auto-indexing:"
echo " Repo → Settings → Webhooks → Add"
echo " URL: http://rag-server:8001/webhook/gitea"
echo ""
echo -e " ${Y}NOTE: Wikipedia download is 22GB — start it and walk away${N}"