From 8a1780b179e6f0888f627995783d9d4bc6f55348 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:05:25 +0000 Subject: [PATCH 1/3] Remove obsolete update_script.sh Prep for full rewrite with MCP server, enhanced RAG, and Claude Code parity. https://claude.ai/code/session_012gDnantBmFTWZGCiKyjazx --- update_script.sh | 241 ----------------------------------------------- 1 file changed, 241 deletions(-) delete mode 100644 update_script.sh diff --git a/update_script.sh b/update_script.sh deleted file mode 100644 index 91cddc9..0000000 --- a/update_script.sh +++ /dev/null @@ -1,241 +0,0 @@ -#!/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=&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}" From 0f6d09db1234b62d6b4d428aa5c01e4a4fe8f0e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 12:50:59 +0000 Subject: [PATCH 2/3] Refactor setup: new/update detection, split Python servers, add MCP server - 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 --- laptop_full_setup.sh | 897 ++++++++++++++++--------------------------- mcp_server.py | 203 ++++++++++ server.py | 281 ++++++++++++++ 3 files changed, 823 insertions(+), 558 deletions(-) mode change 100644 => 100755 laptop_full_setup.sh create mode 100644 mcp_server.py create mode 100644 server.py diff --git a/laptop_full_setup.sh b/laptop_full_setup.sh old mode 100644 new mode 100755 index b27e6da..1aad8c9 --- a/laptop_full_setup.sh +++ b/laptop_full_setup.sh @@ -1,364 +1,157 @@ -#!/bin/bash +#!/usr/bin/env 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 +# 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 -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}"; } +# ── 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}"; } -# All paths relative to this base — no exceptions +# ── args ────────────────────────────────────────────────────────────────────── +FORCE=false +for arg in "$@"; do [[ "$arg" == "--force" ]] && FORCE=true; done + +# ── config ──────────────────────────────────────────────────────────────────── 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 +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 +} # ============================================================================= -sec "STEP 1 — Configuration" +section "Prerequisites" # ============================================================================= -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 +if ! $IS_UPDATE; then -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 + # 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 -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 + # 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 -# ============================================================================= -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" + ok "Skipping prereq checks (update mode)" fi # ============================================================================= -sec "STEP 3 — Directory Structure" +section "Directories" # ============================================================================= -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" +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" # ============================================================================= -sec "STEP 4 — RAG Server Files" +section "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" +# 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 -langchain -langchain-community -langchain-chroma -langchain-ollama -langchain-text-splitters pypdf -unstructured watchdog python-multipart REQ -ok "requirements.txt written" +ok "requirements.txt" + +# MCP server dependencies +cat > "$BASE/mcp_requirements.txt" << 'REQ' +mcp[cli] +fastapi +uvicorn[standard] +httpx +REQ +ok "mcp_requirements.txt" # ============================================================================= -sec "STEP 5 — SearXNG Config" +section "SearXNG Config" # ============================================================================= -cat > "$BASE/searxng/settings.yml" << SEARXNG +write_if_new "$BASE/searxng/settings.yml" << SEARXNG use_default_settings: true general: instance_name: "Local Search" @@ -370,34 +163,39 @@ search: default_lang: "en" formats: [html, json] SEARXNG -ok "SearXNG config written" # ============================================================================= -sec "STEP 6 — Docker Compose" +section ".env File" # ============================================================================= -# Write .env file so BASE path is available in compose -cat > "$BASE/.env" << ENV -BASE=$BASE +# 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 -# ============================================================================= -# 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 -# ============================================================================= +# 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 — Local LLM inference ────────────────────────────────────────── + # ── Ollama — LLM inference ────────────────────────────────────────────────── ollama: image: ollama/ollama:latest container_name: ollama @@ -407,10 +205,10 @@ services: volumes: - ollama-models:/root/.ollama environment: - - OLLAMA_NUM_CTX=16384 + - 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 - - OLLAMA_NUM_GPU=999 deploy: resources: reservations: @@ -424,7 +222,7 @@ services: timeout: 10s retries: 5 - # ── Open WebUI — Chat interface ──────────────────────────────────────────── + # ── Open WebUI — Chat interface ───────────────────────────────────────────── open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui @@ -438,7 +236,6 @@ services: - 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=&format=json @@ -447,32 +244,7 @@ services: 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 — Vector store ───────────────────────────────────────────────── chromadb: image: chromadb/chroma:latest container_name: chromadb @@ -490,7 +262,7 @@ services: timeout: 5s retries: 5 - # ── RAG Server ──────────────────────────────────────────────────────────── + # ── RAG Server — code-aware retrieval ────────────────────────────────────── rag-server: image: python:3.11-slim container_name: rag-server @@ -511,11 +283,9 @@ services: - 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 && + 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: @@ -524,7 +294,36 @@ services: ollama: condition: service_healthy - # ── SearXNG — Private web search ───────────────────────────────────────── + # ── 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 @@ -533,17 +332,11 @@ services: - "0.0.0.0:8888:8080" volumes: - $BASE/searxng:/etc/searxng - cap_drop: - - ALL - cap_add: - - CHOWN - - SETGID - - SETUID + 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 — Offline Wikipedia/docs ───────────────────────────────────────── + # Download ZIMs first: ./kiwix_download.sh kiwix: image: ghcr.io/kiwix/kiwix-serve:latest container_name: kiwix @@ -554,7 +347,7 @@ services: - $BASE/kiwix:/data command: "*.zim" - # ── Gitea — Self-hosted Git ──────────────────────────────────────────────── + # ── Gitea — Self-hosted Git ───────────────────────────────────────────────── gitea: image: gitea/gitea:latest container_name: gitea @@ -571,12 +364,32 @@ services: - 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 + - GITEA__webhook__ALLOWED_HOST_LIST=rag-server,mcp-server - # ── Portainer — Docker management UI ───────────────────────────────────── + # ── 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 @@ -586,123 +399,74 @@ services: - "0.0.0.0:9443:9443" volumes: - /var/run/docker.sock:/var/run/docker.sock - - portainer-data:/data + - $BASE/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" +ok "docker-compose.yml written" # ============================================================================= -sec "STEP 7 — UFW Rules" +section "Firewall (UFW)" # ============================================================================= -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" +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" -# 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) -" + 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 - echo '{"iptables": true}' | sudo tee "$DAEMON_FILE" > /dev/null + info "UFW rules already set (--force to redo)" fi - sudo systemctl restart docker - ok "Docker UFW fix applied" +else + warn "ufw not found — skipping firewall config" fi # ============================================================================= -sec "STEP 8 — Helper Scripts" +section "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" +echo "Pulling latest images..." +docker compose pull --quiet 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 " 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 " Generated images → $BASE/invokeai-outputs/" -echo " Drop PDFs here → $BASE/papers/" +echo " Workspace → $BASE/workspace/" +echo " Drop PDFs → $BASE/papers/" +echo " Images out → $BASE/invokeai-outputs/" echo "" -echo " First run? Pull models: bash $BASE/pull-models.sh" -echo "" -echo " InvokeAI tip: upload jesus.safetensors via" -echo " Model Manager → LoRA → Import" +echo " Claude Code MCP:" +echo " claude mcp add local http://$LOCAL_IP:8002/sse" STARTSH chmod +x "$BASE/start.sh" +ok "start.sh" -# ── stop.sh ─────────────────────────────────────────────────────────────────── cat > "$BASE/stop.sh" << STOPSH #!/bin/bash cd "$BASE" @@ -710,75 +474,88 @@ docker compose down echo "All services stopped." STOPSH chmod +x "$BASE/stop.sh" +ok "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 + --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 loaded ===" -docker exec ollama ollama ps 2>/dev/null || echo " (not running)" +echo "=== Ollama models ===" +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 "=== RAG ===" +curl -s http://localhost:8001/health | python3 -m json.tool 2>/dev/null \ + || echo "(not running)" echo "" -echo "=== Disk usage ===" -du -sh "$BASE"/*/ 2>/dev/null +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" -# ── 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 -# ============================================================= +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." -# 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 } +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 } } } - -# 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" +section "Systemd Auto-Start" # ============================================================================= -sudo bash -c "cat > /etc/systemd/system/laptop-ai.service << SYSD +sudo tee /etc/systemd/system/local-ai.service > /dev/null << SYSD [Unit] -Description=Laptop AI Stack (InvokeAI + Ollama + Open WebUI + RAG) +Description=Local AI Stack After=docker.service network-online.target Requires=docker.service @@ -793,47 +570,51 @@ TimeoutStartSec=300 [Install] WantedBy=multi-user.target -SYSD" +SYSD sudo systemctl daemon-reload -sudo systemctl enable laptop-ai.service -ok "Systemd auto-start enabled (laptop-ai.service)" +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 "${G}════════════════════════════════════════════════════${N}" -echo -e "${G} Setup complete!${N}" -echo -e "${G}════════════════════════════════════════════════════${N}" +echo -e "${GREEN}${BOLD}━━━ Done! ━━━${NC}" echo "" -echo -e " ${C}Everything lives under:${N} $BASE" +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 " ${C}NEXT STEPS${N}" -echo " ──────────────────────────────────────────────────" -echo " 1. Start the stack:" -echo " bash $BASE/start.sh" +echo -e " ${YELLOW}Add MCP to Claude Code:${NC}" +echo " claude mcp add local http://$LOCAL_IP:8002/sse" echo "" -echo " 2. Pull AI models (10-40 min):" -echo " bash $BASE/pull-models.sh" +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 "" -echo " 3. Upload jesus.safetensors to InvokeAI:" -echo " Open http://$LOCAL_IP:9090" -echo " → Model Manager → LoRA → Import" +$IS_UPDATE && echo -e " ${GREEN}Update complete.${NC}" \ + || echo -e " ${GREEN}Fresh install complete.${NC}" 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}" diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..4c30779 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +MCP Server — Claude Code-equivalent tools for Open WebUI / Claude Code CLI. +Tools: bash, file read/write/list, code search, git ops, Gitea API, repo ingest. +Connects via SSE on port 8002 — add to Open WebUI Tools or ~/.claude/mcp.json +""" +import os, subprocess, textwrap +from pathlib import Path + +import httpx +from mcp.server.fastmcp import FastMCP + +WORKSPACE = Path(os.getenv("WORKSPACE_DIR", "/workspace")) +REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos")) +GITEA_URL = os.getenv("GITEA_URL", "http://gitea:3000") +GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") +GITHUB_TOKEN= os.getenv("GITHUB_TOKEN","") +RAG_URL = os.getenv("RAG_URL", "http://rag-server:8001") + +mcp = FastMCP("local-dev-tools") + +# ── bash ────────────────────────────────────────────────────────────────────── +@mcp.tool() +def bash(command: str, cwd: str = "") -> str: + """Run a shell command. Default cwd is /workspace.""" + work = Path(cwd) if cwd else WORKSPACE + work.mkdir(parents=True, exist_ok=True) + try: + r = subprocess.run(command, shell=True, cwd=work, timeout=120, + capture_output=True, text=True) + out = r.stdout + (f"\n[stderr]\n{r.stderr}" if r.stderr else "") + if r.returncode != 0: + out += f"\n[exit {r.returncode}]" + return out or "(no output)" + except subprocess.TimeoutExpired: + return "[timeout after 120s]" + except Exception as e: + return f"[error] {e}" + +# ── file ops ────────────────────────────────────────────────────────────────── +@mcp.tool() +def read_file(path: str) -> str: + """Read a file. Use absolute path or relative to /workspace.""" + p = Path(path) if Path(path).is_absolute() else WORKSPACE / path + if not p.exists(): + return f"[not found] {p}" + if p.stat().st_size > 500_000: + return f"[too large — {p.stat().st_size//1024}KB]" + return p.read_text(encoding="utf-8", errors="replace") + +@mcp.tool() +def write_file(path: str, content: str) -> str: + """Write content to a file (creates parent dirs). Relative to /workspace.""" + p = Path(path) if Path(path).is_absolute() else WORKSPACE / path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return f"Wrote {len(content)} chars to {p}" + +@mcp.tool() +def list_files(path: str = "", pattern: str = "**/*") -> str: + """List files matching a glob pattern.""" + base = Path(path) if path else WORKSPACE + if not base.exists(): + return f"[not found] {base}" + files = sorted(str(f.relative_to(base)) for f in base.glob(pattern) if f.is_file()) + return "\n".join(files[:500]) or "(empty)" + +@mcp.tool() +def search_code(query: str, path: str = "", glob: str = "", + case_sensitive: bool = False) -> str: + """Search file contents with ripgrep. Returns file:line matches.""" + base = path or str(WORKSPACE) + cmd = ["rg", "--line-number", "--no-heading"] + if not case_sensitive: + cmd.append("-i") + if glob: + cmd += ["-g", glob] + cmd += [query, base] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + lines = r.stdout.strip().splitlines() + if len(lines) > 200: + lines = lines[:200] + [f"… ({len(r.stdout.splitlines())-200} more)"] + return "\n".join(lines) or "(no matches)" + except FileNotFoundError: + # ripgrep not installed, fall back to grep + r = subprocess.run(["grep", "-rn", query, base], + capture_output=True, text=True, timeout=30) + return r.stdout[:8000] or "(no matches)" + except Exception as e: + return f"[error] {e}" + +# ── git ─────────────────────────────────────────────────────────────────────── +def _git(args: list[str], repo: str = "") -> str: + cwd = Path(repo) if repo else WORKSPACE + r = subprocess.run(["git"] + args, cwd=cwd, + capture_output=True, text=True, timeout=60) + return (r.stdout + r.stderr).strip() or "(no output)" + +@mcp.tool() +def git_status(repo: str = "") -> str: + """Show git status of a repo (default: /workspace).""" + return _git(["status", "--short"], repo) + +@mcp.tool() +def git_diff(repo: str = "", cached: bool = False) -> str: + """Show git diff (staged if cached=True).""" + args = ["diff", "--stat", "--cached"] if cached else ["diff", "--stat"] + return _git(args, repo) + "\n\n" + _git( + ["diff", "--cached"] if cached else ["diff"], repo) + +@mcp.tool() +def git_log(repo: str = "", n: int = 10) -> str: + """Show last n git commits.""" + return _git(["log", f"-{n}", "--oneline", "--decorate"], repo) + +@mcp.tool() +def git_commit(message: str, repo: str = "", add_all: bool = True) -> str: + """Stage all changes and create a commit.""" + if add_all: + _git(["add", "-A"], repo) + return _git(["commit", "-m", message], repo) + +@mcp.tool() +def git_checkout(branch: str, repo: str = "", create: bool = False) -> str: + """Checkout a branch, optionally creating it.""" + args = ["checkout", "-b", branch] if create else ["checkout", branch] + return _git(args, repo) + +# ── Gitea API ───────────────────────────────────────────────────────────────── +def _gitea(method: str, path: str, body: dict = {}) -> dict: + if not GITEA_TOKEN: + return {"error": "GITEA_TOKEN not set in .env"} + url = f"{GITEA_URL}/api/v1{path}" + headers = {"Authorization": f"token {GITEA_TOKEN}", + "Content-Type": "application/json"} + r = httpx.request(method, url, json=body or None, headers=headers, timeout=30) + try: + return r.json() + except Exception: + return {"status": r.status_code, "text": r.text} + +@mcp.tool() +def gitea_list_repos() -> str: + """List your Gitea repos.""" + repos = _gitea("GET", "/repos/search?limit=50") + if "error" in repos: + return repos["error"] + return "\n".join(f"{r['full_name']} — {r.get('description','')}" + for r in repos.get("data", [])) + +@mcp.tool() +def gitea_create_repo(name: str, private: bool = True, description: str = "") -> str: + """Create a new Gitea repository.""" + r = _gitea("POST", "/user/repos", + {"name": name, "private": private, "description": description, + "auto_init": True, "default_branch": "main"}) + return r.get("html_url") or str(r) + +@mcp.tool() +def gitea_create_issue(repo: str, title: str, body: str = "") -> str: + """Create an issue on a Gitea repo (format: owner/repo).""" + r = _gitea("POST", f"/repos/{repo}/issues", {"title": title, "body": body}) + return r.get("html_url") or str(r) + +# ── GitHub API ──────────────────────────────────────────────────────────────── +@mcp.tool() +def github_api(method: str, endpoint: str, body: str = "") -> str: + """Call the GitHub REST API. endpoint e.g. /repos/owner/repo/issues""" + if not GITHUB_TOKEN: + return "GITHUB_TOKEN not set in .env" + import json as _json + headers = {"Authorization": f"Bearer {GITHUB_TOKEN}", + "Accept": "application/vnd.github+json"} + r = httpx.request(method.upper(), f"https://api.github.com{endpoint}", + json=_json.loads(body) if body else None, + headers=headers, timeout=30) + try: + return _json.dumps(r.json(), indent=2) + except Exception: + return r.text + +# ── RAG ingest ──────────────────────────────────────────────────────────────── +@mcp.tool() +def ingest_repo(url: str, name: str = "", branch: str = "main") -> str: + """Clone a git repo and index it in the RAG code collection.""" + r = httpx.post(f"{RAG_URL}/ingest/repo", + json={"url": url, "name": name, "branch": branch}, timeout=300) + return r.text + +@mcp.tool() +def rag_health() -> str: + """Check RAG server status and indexed document counts.""" + try: + r = httpx.get(f"{RAG_URL}/health", timeout=10) + return r.text + except Exception as e: + return f"RAG server unreachable: {e}" + +if __name__ == "__main__": + import uvicorn + app = mcp.get_asgi_app() + uvicorn.run(app, host="0.0.0.0", port=8002) diff --git a/server.py b/server.py new file mode 100644 index 0000000..ddf2cb9 --- /dev/null +++ b/server.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +RAG Server — code-aware chunking, multi-collection, repo ingest, webhooks. +Collections: papers (PDFs/text), code (source files, AST-split for Python) +""" +import ast, fnmatch, hashlib, json, logging, os, re, subprocess, threading, time +from pathlib import Path +from typing import Any, Optional + +import chromadb +import httpx +from chromadb.utils.embedding_functions import OllamaEmbeddingFunction +from fastapi import FastAPI, HTTPException, Request, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +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")) +TOP_K = int(os.getenv("TOP_K", "6")) + +CODE_EXTS = {".py",".js",".ts",".tsx",".jsx",".go",".rs",".java",".c",".cpp", + ".h",".hpp",".cs",".rb",".sh",".yaml",".yml",".toml",".sql",".md"} +SKIP_DIRS = {"node_modules",".git","__pycache__","dist","build",".venv", + "venv","env",".next","vendor","target","bin","obj"} +SKIP_FILES = {"package-lock.json","yarn.lock","pnpm-lock.yaml","Cargo.lock"} +MAX_BYTES = 400_000 + +app = FastAPI(title="RAG Server") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# ── ChromaDB ────────────────────────────────────────────────────────────────── +def _embed_fn(): + return OllamaEmbeddingFunction( + url=f"{OLLAMA_URL}/api/embeddings", model_name=EMBED_MODEL) + +def _chroma(): + host, port = CHROMA_URL.replace("http://","").split(":") + return chromadb.HttpClient(host=host, port=int(port)) + +def get_col(name: str): + return _chroma().get_or_create_collection(name, embedding_function=_embed_fn()) + +# ── chunkers ───────────────────────────────────────────────────────────────── +def _doc_id(text: str, key: str) -> str: + return hashlib.md5(f"{key}|{text[:200]}".encode()).hexdigest() + +def _sliding(text: str, size=1000, overlap=150) -> list[str]: + chunks, i = [], 0 + while i < len(text): + chunks.append(text[i:i+size]) + i += size - overlap + return [c for c in chunks if c.strip()] + +def _chunk_python(src: str) -> list[tuple[str,str]]: + try: + tree = ast.parse(src) + except SyntaxError: + return [] + lines = src.splitlines() + out = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + chunk = "\n".join(lines[node.lineno-1:node.end_lineno]) + out.append((node.name, chunk[:4000])) + return out + +def _chunk_file(path: Path, src: str) -> list[tuple[str,str]]: + if path.suffix == ".py": + pairs = _chunk_python(src) + if pairs: + return pairs + # function/class boundary split for JS/TS/Go/Rust etc. + pat = re.compile( + r'(?:^|\n)(?=(?:export\s+)?(?:async\s+)?(?:function|class|const\s+\w+\s*=\s*(?:async\s+)?\()' + r'|^func |^type |^impl |^pub fn |^fn )', + re.MULTILINE) + parts = [p.strip() for p in pat.split(src) if p.strip()] + if len(parts) > 1: + return [(f"s{i}", p[:4000]) for i, p in enumerate(parts)] + return [(f"c{i}", c) for i, c in enumerate(_sliding(src, 1200, 200))] + +# ── ingest helpers ──────────────────────────────────────────────────────────── +def ingest_file(col, fpath: Path, repo: str = ""): + if fpath.stat().st_size > MAX_BYTES or fpath.name in SKIP_FILES: + return + if any(fnmatch.fnmatch(fpath.name, p) for p in ("*.min.js","*.min.css","*.map")): + return + try: + src = fpath.read_text(encoding="utf-8", errors="ignore") + except Exception: + return + if not src.strip(): + return + rel = str(fpath) + pairs = _chunk_file(fpath, src) if fpath.suffix in CODE_EXTS else \ + [(f"c{i}", c) for i, c in enumerate(_sliding(src))] + ids, docs, metas = [], [], [] + for label, chunk in pairs: + if not chunk.strip(): + continue + ids.append(_doc_id(chunk, rel+label)) + docs.append(chunk) + metas.append({"source": rel, "label": label, "repo": repo, + "lang": fpath.suffix.lstrip(".")}) + if ids: + col.upsert(ids=ids, documents=docs, metadatas=metas) + +def ingest_dir(col, directory: Path, repo: str = "") -> int: + count = 0 + for f in directory.rglob("*"): + if not f.is_file(): + continue + if any(p in f.parts for p in SKIP_DIRS): + continue + ingest_file(col, f, repo) + count += 1 + log.info("Indexed %d files from %s", count, directory) + return count + +def ingest_pdfs(col) -> int: + try: + import pypdf + except ImportError: + log.warning("pypdf not installed — skipping PDFs") + return 0 + n = 0 + for pdf in PAPERS_DIR.glob("*.pdf"): + try: + text = "\n".join(p.extract_text() or "" + for p in pypdf.PdfReader(str(pdf)).pages) + for i, chunk in enumerate(_sliding(text)): + col.upsert(ids=[_doc_id(chunk, str(pdf)+str(i))], + documents=[chunk], + metadatas=[{"source": str(pdf), "label": f"p{i}", + "repo": "", "lang": "pdf"}]) + n += 1 + except Exception as e: + log.warning("PDF %s: %s", pdf.name, e) + return n + +# ── startup ─────────────────────────────────────────────────────────────────── +def _startup_index(): + # wait for embed model + for _ in range(40): + try: + r = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=5) + if any(EMBED_MODEL in m["name"] for m in r.json().get("models", [])): + break + except Exception: + pass + log.info("Waiting for embed model %s…", EMBED_MODEL) + time.sleep(5) + + code_col = get_col("code") + papers_col = get_col("papers") + for d in REPOS_DIR.iterdir(): + if d.is_dir(): + ingest_dir(code_col, d, d.name) + ingest_pdfs(papers_col) + for f in PAPERS_DIR.glob("*.txt"): + ingest_file(papers_col, f) + log.info("Startup index complete") + +@app.on_event("startup") +async def on_startup(): + threading.Thread(target=_startup_index, daemon=True).start() + +# ── endpoints ───────────────────────────────────────────────────────────────── +@app.get("/health") +async def health(): + try: + cc = _chroma() + return {"status": "ok", + "code": cc.get_collection("code", embedding_function=_embed_fn()).count(), + "papers": cc.get_collection("papers", embedding_function=_embed_fn()).count(), + "embed": EMBED_MODEL, "chat": CHAT_MODEL} + except Exception as e: + return {"status": "error", "detail": str(e)} + +class RepoRequest(BaseModel): + url: str + name: str = "" + branch: str = "main" + +@app.post("/ingest/repo") +async def ingest_repo(req: RepoRequest): + name = req.name or req.url.rstrip("/").split("/")[-1].removesuffix(".git") + dest = REPOS_DIR / name + try: + if dest.exists(): + subprocess.run(["git","pull"], cwd=dest, check=True, timeout=120) + else: + subprocess.run(["git","clone","--depth=1","-b",req.branch, + req.url, str(dest)], check=True, timeout=300) + except subprocess.CalledProcessError as e: + raise HTTPException(400, str(e)) + n = ingest_dir(get_col("code"), dest, name) + return {"status": "ok", "repo": name, "files": n} + +@app.post("/ingest/papers") +async def trigger_papers(bg: BackgroundTasks): + bg.add_task(ingest_pdfs, get_col("papers")) + return {"status": "queued"} + +async def _webhook(payload: dict): + repo = payload.get("repository") or {} + url = repo.get("clone_url") or repo.get("html_url","") + name = repo.get("name","unknown") + if not url: + return {"status": "ignored"} + dest = REPOS_DIR / name + if dest.exists(): + subprocess.run(["git","pull"], cwd=dest, timeout=120) + else: + subprocess.run(["git","clone","--depth=1",url,str(dest)], timeout=300) + n = ingest_dir(get_col("code"), dest, name) + return {"status": "ok", "repo": name, "files": n} + +@app.post("/webhook/gitea") +async def webhook_gitea(r: Request): return await _webhook(await r.json()) + +@app.post("/webhook/github") +async def webhook_github(r: Request): return await _webhook(await r.json()) + +# ── RAG chat ────────────────────────────────────────────────────────────────── +class ChatRequest(BaseModel): + model: str = CHAT_MODEL + messages: list[dict[str,Any]] + stream: bool = False + collections: list[str] = ["code","papers"] + +def _context(query: str, cols: list[str]) -> str: + parts = [] + for cname in cols: + try: + col = get_col(cname) + if col.count() == 0: + continue + res = col.query(query_texts=[query], n_results=min(TOP_K, col.count())) + for doc, meta in zip(res["documents"][0], res["metadatas"][0]): + parts.append(f"### {meta.get('source','')}:{meta.get('label','')}\n" + f"```{meta.get('lang','')}\n{doc}\n```") + except Exception as e: + log.warning("col %s: %s", cname, e) + return "\n\n".join(parts) + +@app.post("/v1/chat/completions") +async def chat(req: ChatRequest): + query = next((m["content"] for m in reversed(req.messages) + if m.get("role")=="user"), "") + context = _context(query, req.collections) + msgs = [{"role":"system","content": + "You are a helpful coding assistant. Use the retrieved context below.\n\n" + f"## Context\n{context}"}] + req.messages + + payload = {"model": req.model, "messages": msgs, "stream": req.stream} + + if req.stream: + async def gen(): + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream("POST", + f"{OLLAMA_URL}/v1/chat/completions", json=payload) as r: + async for chunk in r.aiter_bytes(): + yield chunk + return StreamingResponse(gen(), media_type="text/event-stream") + + async with httpx.AsyncClient(timeout=300) as client: + r = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json=payload) + return r.json() + +if __name__ == "__main__": + import uvicorn + uvicorn.run("server:app", host="0.0.0.0", port=8001, reload=False) From 8a1ac05f1e89fdd19480c8033163ec468edc9d14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 15:14:10 +0000 Subject: [PATCH 3/3] Add single self-contained local-ai-setup.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges all components into one script — no separate server files needed. Embeds server.py (RAG) and mcp_server.py (MCP) as inline heredocs. - Auto-detects VRAM and sets models + context window accordingly: 6GB → 7B models, 8k ctx 8GB+ → 14B chat + 7B code, 16k ctx 16GB → 14B models, 32k ctx (V100/A100 friendly) - New install or update (detects existing compose) - --force flag to overwrite config files - --no-pull to skip model download prompt - Adds fetch_url tool to MCP server - Optional DeepSeek-R1:14b pull for reasoning tasks https://claude.ai/code/session_012gDnantBmFTWZGCiKyjazx --- local-ai-setup.sh | 837 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 837 insertions(+) create mode 100755 local-ai-setup.sh diff --git a/local-ai-setup.sh b/local-ai-setup.sh new file mode 100755 index 0000000..7c61c9f --- /dev/null +++ b/local-ai-setup.sh @@ -0,0 +1,837 @@ +#!/usr/bin/env bash +# Local AI Stack — single script, new install or update +# Usage: ./local-ai-setup.sh [--force] [--no-pull] +set -euo pipefail + +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} $*"; } +section() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; } + +FORCE=false; NO_PULL=false +for a in "$@"; do [[ "$a" == "--force" ]] && FORCE=true; [[ "$a" == "--no-pull" ]] && NO_PULL=true; done + +BASE="$HOME/docker/ai-stack" +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 LAN IP: " LOCAL_IP + +IS_UPDATE=false; [[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true + +# ── detect VRAM and set models accordingly ──────────────────────────────────── +VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ + | head -1 | awk '{printf "%d", $1/1024}' 2>/dev/null || echo "0") + +if [[ "$VRAM_GB" -ge 14 ]]; then + CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:14b" + CTX=32768; TIER="16GB — 14B models + 32k context" +elif [[ "$VRAM_GB" -ge 8 ]]; then + CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:7b" + CTX=16384; TIER="8-16GB — 14B chat, 7B code, 16k context" +elif [[ "$VRAM_GB" -ge 4 ]]; then + CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" + CTX=8192; TIER="6GB — 7B models, 8k context" +else + CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" + CTX=4096; TIER="CPU-only — 7B models, 4k context" +fi +EMBED_MODEL="nomic-embed-text" + +section "Local AI Stack — $($IS_UPDATE && echo UPDATE || echo NEW INSTALL)" +info "Base : $BASE" +info "IP : $LOCAL_IP" +info "GPU : ${VRAM_GB}GB VRAM → $TIER" + +write_if_new() { + local dest="$1"; local body; body=$(cat) + if [[ ! -f "$dest" ]] || $FORCE; then + printf '%s\n' "$body" > "$dest"; ok "Wrote $(basename "$dest")" + else + info "Kept $(basename "$dest") (--force to overwrite)" + fi +} + +# ── prereqs (new install only) ──────────────────────────────────────────────── +if ! $IS_UPDATE; then + section "Prerequisites" + if ! command -v docker &>/dev/null; then + info "Installing Docker..." + curl -fsSL https://get.docker.com | sh + sudo usermod -aG docker "$USER" + warn "Run: newgrp docker (or log out/in)" + else + ok "Docker: $(docker --version | cut -d' ' -f3)" + fi + + if command -v nvidia-smi &>/dev/null && ! dpkg -l 2>/dev/null | grep -q nvidia-container-toolkit; then + info "Installing NVIDIA Container Toolkit..." + dist=$(. /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/$dist/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" + fi + command -v rg &>/dev/null || sudo apt-get install -y ripgrep +fi + +# ── directories ─────────────────────────────────────────────────────────────── +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 "Ready under $BASE" + +# ============================================================================= +section "Writing server.py (RAG)" +# ============================================================================= +write_if_new "$BASE/server.py" << 'PY' +import ast, fnmatch, hashlib, json, logging, os, re, subprocess, threading, time +from pathlib import Path +from typing import Any +import chromadb, httpx +from chromadb.utils.embedding_functions import OllamaEmbeddingFunction +from fastapi import FastAPI, HTTPException, Request, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +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")) +TOP_K = int(os.getenv("TOP_K", "6")) + +CODE_EXTS = {".py",".js",".ts",".tsx",".jsx",".go",".rs",".java",".c",".cpp", + ".h",".cs",".rb",".sh",".yaml",".yml",".toml",".sql",".md"} +SKIP_DIRS = {"node_modules",".git","__pycache__","dist","build",".venv","venv","target"} +MAX_BYTES = 400_000 + +app = FastAPI(title="RAG Server") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +def _embed_fn(): + return OllamaEmbeddingFunction(url=f"{OLLAMA_URL}/api/embeddings", model_name=EMBED_MODEL) + +def _chroma(): + host, port = CHROMA_URL.replace("http://","").split(":") + return chromadb.HttpClient(host=host, port=int(port)) + +def get_col(name): + return _chroma().get_or_create_collection(name, embedding_function=_embed_fn()) + +def _doc_id(text, key): + return hashlib.md5(f"{key}|{text[:200]}".encode()).hexdigest() + +def _sliding(text, size=1000, overlap=150): + chunks, i = [], 0 + while i < len(text): + chunks.append(text[i:i+size]); i += size - overlap + return [c for c in chunks if c.strip()] + +def _chunk_python(src): + try: tree = ast.parse(src) + except SyntaxError: return [] + lines = src.splitlines(); out = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + out.append((node.name, "\n".join(lines[node.lineno-1:node.end_lineno])[:4000])) + return out + +def _chunk_file(path, src): + if path.suffix == ".py": + pairs = _chunk_python(src) + if pairs: return pairs + pat = re.compile(r'(?:^|\n)(?=(?:export\s+)?(?:async\s+)?(?:function|class)|^func |^type |^impl |^pub fn |^fn )', re.M) + parts = [p.strip() for p in pat.split(src) if p.strip()] + if len(parts) > 1: return [(f"s{i}", p[:4000]) for i,p in enumerate(parts)] + return [(f"c{i}", c) for i,c in enumerate(_sliding(src, 1200, 200))] + +def ingest_file(col, fpath, repo=""): + if fpath.stat().st_size > MAX_BYTES: return + if any(fnmatch.fnmatch(fpath.name, p) for p in ("*.min.js","*.map","package-lock.json","yarn.lock")): return + try: src = fpath.read_text(encoding="utf-8", errors="ignore") + except: return + if not src.strip(): return + rel = str(fpath) + pairs = _chunk_file(fpath, src) if fpath.suffix in CODE_EXTS else \ + [(f"c{i}",c) for i,c in enumerate(_sliding(src))] + ids,docs,metas = [],[],[] + for label,chunk in pairs: + if not chunk.strip(): continue + ids.append(_doc_id(chunk, rel+label)); docs.append(chunk) + metas.append({"source":rel,"label":label,"repo":repo,"lang":fpath.suffix.lstrip(".")}) + if ids: col.upsert(ids=ids, documents=docs, metadatas=metas) + +def ingest_dir(col, directory, repo=""): + count = 0 + for f in directory.rglob("*"): + if not f.is_file() or any(p in f.parts for p in SKIP_DIRS): continue + ingest_file(col, f, repo); count += 1 + log.info("Indexed %d files from %s", count, directory); return count + +def ingest_pdfs(col): + try: import pypdf + except ImportError: return 0 + n = 0 + for pdf in PAPERS_DIR.glob("*.pdf"): + try: + text = "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(str(pdf)).pages) + for i,chunk in enumerate(_sliding(text)): + col.upsert(ids=[_doc_id(chunk,str(pdf)+str(i))], documents=[chunk], + metadatas=[{"source":str(pdf),"label":f"p{i}","repo":"","lang":"pdf"}]) + n += 1 + except Exception as e: log.warning("PDF %s: %s", pdf.name, e) + return n + +def _startup(): + for _ in range(40): + try: + r = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=5) + if any(EMBED_MODEL in m["name"] for m in r.json().get("models",[])): break + except: pass + log.info("Waiting for embed model..."); time.sleep(5) + code_col = get_col("code"); papers_col = get_col("papers") + for d in REPOS_DIR.iterdir(): + if d.is_dir(): ingest_dir(code_col, d, d.name) + ingest_pdfs(papers_col) + for f in PAPERS_DIR.glob("*.txt"): ingest_file(papers_col, f) + log.info("Startup index done") + +@app.on_event("startup") +async def on_startup(): threading.Thread(target=_startup, daemon=True).start() + +@app.get("/health") +async def health(): + try: + cc = _chroma() + return {"status":"ok", + "code": cc.get_collection("code", embedding_function=_embed_fn()).count(), + "papers": cc.get_collection("papers", embedding_function=_embed_fn()).count()} + except Exception as e: return {"status":"error","detail":str(e)} + +class RepoReq(BaseModel): + url: str; name: str = ""; branch: str = "main" + +@app.post("/ingest/repo") +async def ingest_repo(req: RepoReq): + name = req.name or req.url.rstrip("/").split("/")[-1].removesuffix(".git") + dest = REPOS_DIR / name + try: + if dest.exists(): subprocess.run(["git","pull"], cwd=dest, check=True, timeout=120) + else: subprocess.run(["git","clone","--depth=1","-b",req.branch,req.url,str(dest)], check=True, timeout=300) + except subprocess.CalledProcessError as e: raise HTTPException(400, str(e)) + return {"status":"ok","repo":name,"files":ingest_dir(get_col("code"),dest,name)} + +@app.post("/ingest/papers") +async def trigger_papers(bg: BackgroundTasks): + bg.add_task(ingest_pdfs, get_col("papers")); return {"status":"queued"} + +async def _webhook(payload): + repo = payload.get("repository") or {} + url = repo.get("clone_url") or repo.get("html_url",""); name = repo.get("name","unknown") + if not url: return {"status":"ignored"} + dest = REPOS_DIR / name + if dest.exists(): subprocess.run(["git","pull"], cwd=dest, timeout=120) + else: subprocess.run(["git","clone","--depth=1",url,str(dest)], timeout=300) + return {"status":"ok","repo":name,"files":ingest_dir(get_col("code"),dest,name)} + +@app.post("/webhook/gitea") +async def wh_gitea(r: Request): return await _webhook(await r.json()) +@app.post("/webhook/github") +async def wh_github(r: Request): return await _webhook(await r.json()) + +def _ctx(query, cols): + parts = [] + for cn in cols: + try: + col = get_col(cn) + if col.count() == 0: continue + res = col.query(query_texts=[query], n_results=min(TOP_K, col.count())) + for doc,meta in zip(res["documents"][0], res["metadatas"][0]): + parts.append(f"### {meta.get('source','')}:{meta.get('label','')}\n```{meta.get('lang','')}\n{doc}\n```") + except Exception as e: log.warning("col %s: %s", cn, e) + return "\n\n".join(parts) + +class ChatReq(BaseModel): + model: str = CHAT_MODEL; messages: list[dict[str,Any]] + stream: bool = False; collections: list[str] = ["code","papers"] + +@app.post("/v1/chat/completions") +async def chat(req: ChatReq): + query = next((m["content"] for m in reversed(req.messages) if m.get("role")=="user"), "") + ctx = _ctx(query, req.collections) + msgs = [{"role":"system","content":f"You are a coding assistant. Use context below.\n\n## Context\n{ctx}"}] + req.messages + payload = {"model":req.model,"messages":msgs,"stream":req.stream} + if req.stream: + async def gen(): + async with httpx.AsyncClient(timeout=300) as c: + async with c.stream("POST",f"{OLLAMA_URL}/v1/chat/completions",json=payload) as r: + async for chunk in r.aiter_bytes(): yield chunk + return StreamingResponse(gen(), media_type="text/event-stream") + async with httpx.AsyncClient(timeout=300) as c: + r = await c.post(f"{OLLAMA_URL}/v1/chat/completions", json=payload) + return r.json() + +if __name__ == "__main__": + import uvicorn; uvicorn.run("server:app", host="0.0.0.0", port=8001, reload=False) +PY + +# ============================================================================= +section "Writing mcp_server.py" +# ============================================================================= +write_if_new "$BASE/mcp_server.py" << 'PY' +import json, os, re, subprocess +from pathlib import Path +import httpx +from mcp.server.fastmcp import FastMCP + +WORKSPACE = Path(os.getenv("WORKSPACE_DIR", "/workspace")) +REPOS_DIR = Path(os.getenv("REPOS_DIR", "/repos")) +GITEA_URL = os.getenv("GITEA_URL", "http://gitea:3000") +GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN","") +RAG_URL = os.getenv("RAG_URL", "http://rag-server:8001") + +mcp = FastMCP("local-dev-tools") + +@mcp.tool() +def bash(command: str, cwd: str = "") -> str: + """Run a shell command. Default cwd is /workspace.""" + work = Path(cwd) if cwd else WORKSPACE + work.mkdir(parents=True, exist_ok=True) + try: + r = subprocess.run(command, shell=True, cwd=work, timeout=120, capture_output=True, text=True) + out = r.stdout + (f"\n[stderr]\n{r.stderr}" if r.stderr else "") + if r.returncode != 0: out += f"\n[exit {r.returncode}]" + return out or "(no output)" + except subprocess.TimeoutExpired: return "[timeout]" + except Exception as e: return f"[error] {e}" + +@mcp.tool() +def read_file(path: str) -> str: + """Read a file. Absolute or relative to /workspace.""" + p = Path(path) if Path(path).is_absolute() else WORKSPACE / path + if not p.exists(): return f"[not found] {p}" + if p.stat().st_size > 500_000: return f"[too large: {p.stat().st_size//1024}KB]" + return p.read_text(encoding="utf-8", errors="replace") + +@mcp.tool() +def write_file(path: str, content: str) -> str: + """Write content to a file. Relative to /workspace.""" + p = Path(path) if Path(path).is_absolute() else WORKSPACE / path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return f"Wrote {len(content)} chars to {p}" + +@mcp.tool() +def list_files(path: str = "", pattern: str = "**/*") -> str: + """List files matching a glob pattern.""" + base = Path(path) if path else WORKSPACE + if not base.exists(): return f"[not found] {base}" + files = sorted(str(f.relative_to(base)) for f in base.glob(pattern) if f.is_file()) + return "\n".join(files[:500]) or "(empty)" + +@mcp.tool() +def search_code(query: str, path: str = "", glob: str = "", case_sensitive: bool = False) -> str: + """Search file contents with ripgrep.""" + base = path or str(WORKSPACE) + cmd = ["rg", "--line-number", "--no-heading"] + if not case_sensitive: cmd.append("-i") + if glob: cmd += ["-g", glob] + cmd += [query, base] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + lines = r.stdout.strip().splitlines() + if len(lines) > 200: lines = lines[:200] + [f"...({len(r.stdout.splitlines())-200} more)"] + return "\n".join(lines) or "(no matches)" + except FileNotFoundError: + r = subprocess.run(["grep","-rn",query,base], capture_output=True, text=True, timeout=30) + return r.stdout[:8000] or "(no matches)" + +@mcp.tool() +def fetch_url(url: str, extract_text: bool = True) -> str: + """Fetch the content of a URL.""" + try: + r = httpx.get(url, timeout=30, follow_redirects=True, headers={"User-Agent":"Mozilla/5.0"}) + content = r.text + if extract_text: + content = re.sub(r']*>.*?', '', content, flags=re.DOTALL) + content = re.sub(r']*>.*?', '', content, flags=re.DOTALL) + content = re.sub(r'<[^>]+>', '', content) + content = re.sub(r'\n{3,}', '\n\n', content).strip() + return content[:20000] + except Exception as e: return f"[error] {e}" + +def _git(args, repo=""): + cwd = Path(repo) if repo else WORKSPACE + r = subprocess.run(["git"]+args, cwd=cwd, capture_output=True, text=True, timeout=60) + return (r.stdout + r.stderr).strip() or "(no output)" + +@mcp.tool() +def git_status(repo: str = "") -> str: + """Show git status.""" + return _git(["status","--short"], repo) + +@mcp.tool() +def git_diff(repo: str = "", cached: bool = False) -> str: + """Show git diff.""" + flag = ["--cached"] if cached else [] + return _git(["diff","--stat"]+flag, repo) + "\n\n" + _git(["diff"]+flag, repo) + +@mcp.tool() +def git_log(repo: str = "", n: int = 10) -> str: + """Show last n commits.""" + return _git(["log",f"-{n}","--oneline","--decorate"], repo) + +@mcp.tool() +def git_commit(message: str, repo: str = "", add_all: bool = True) -> str: + """Stage all and commit.""" + if add_all: _git(["add","-A"], repo) + return _git(["commit","-m",message], repo) + +@mcp.tool() +def git_checkout(branch: str, repo: str = "", create: bool = False) -> str: + """Checkout or create a branch.""" + return _git(["checkout","-b",branch] if create else ["checkout",branch], repo) + +def _gitea(method, path, body=None): + if not GITEA_TOKEN: return {"error":"GITEA_TOKEN not set in .env"} + r = httpx.request(method, f"{GITEA_URL}/api/v1{path}", + json=body, headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=30) + try: return r.json() + except: return {"status":r.status_code,"text":r.text} + +@mcp.tool() +def gitea_list_repos() -> str: + """List your Gitea repos.""" + d = _gitea("GET", "/repos/search?limit=50") + if "error" in d: return d["error"] + return "\n".join(f"{r['full_name']} — {r.get('description','')}" for r in d.get("data",[])) + +@mcp.tool() +def gitea_create_repo(name: str, private: bool = True, description: str = "") -> str: + """Create a Gitea repo.""" + r = _gitea("POST","/user/repos",{"name":name,"private":private,"description":description,"auto_init":True,"default_branch":"main"}) + return r.get("html_url") or str(r) + +@mcp.tool() +def gitea_create_issue(repo: str, title: str, body: str = "") -> str: + """Create a Gitea issue (owner/repo).""" + r = _gitea("POST",f"/repos/{repo}/issues",{"title":title,"body":body}) + return r.get("html_url") or str(r) + +@mcp.tool() +def github_api(method: str, endpoint: str, body: str = "") -> str: + """Call GitHub REST API. endpoint e.g. /repos/owner/repo/issues""" + if not GITHUB_TOKEN: return "GITHUB_TOKEN not set in .env" + r = httpx.request(method.upper(), f"https://api.github.com{endpoint}", + json=json.loads(body) if body else None, + headers={"Authorization":f"Bearer {GITHUB_TOKEN}","Accept":"application/vnd.github+json"}, + timeout=30) + try: return json.dumps(r.json(), indent=2) + except: return r.text + +@mcp.tool() +def ingest_repo(url: str, name: str = "", branch: str = "main") -> str: + """Clone a repo and index it in RAG.""" + r = httpx.post(f"{RAG_URL}/ingest/repo", json={"url":url,"name":name,"branch":branch}, timeout=300) + return r.text + +@mcp.tool() +def rag_health() -> str: + """Check RAG server status.""" + try: return httpx.get(f"{RAG_URL}/health", timeout=10).text + except Exception as e: return f"RAG unreachable: {e}" + +if __name__ == "__main__": + import uvicorn + uvicorn.run(mcp.get_asgi_app(), host="0.0.0.0", port=8002) +PY + +# ============================================================================= +section "Requirements" +# ============================================================================= +cat > "$BASE/requirements.txt" << 'REQ' +fastapi +uvicorn[standard] +httpx +pydantic +chromadb +pypdf +python-multipart +REQ + +cat > "$BASE/mcp_requirements.txt" << 'REQ' +mcp[cli] +fastapi +uvicorn[standard] +httpx +REQ +ok "requirements.txt + 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 (tokens — never overwritten)" +# ============================================================================= +if [[ ! -f "$BASE/.env" ]]; then + cat > "$BASE/.env" << ENV +# Local AI Stack — edit to add your API tokens +GITEA_TOKEN=your-gitea-token-here +GITHUB_TOKEN=your-github-token-here +ENV + ok "Created .env" +else + info "Kept .env" +fi + +# ============================================================================= +section "Docker Compose" +# ============================================================================= +cat > "$BASE/docker-compose.yml" << COMPOSE +# Local AI Stack — 2026-03-20 +# GPU: OLLAMA_NUM_GPU=999 uses all available VRAM automatically (V100/RTX/any) +# Context: OLLAMA_NUM_CTX= set by detected VRAM (GB) + +services: + + 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 + - OLLAMA_NUM_CTX= + - 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: + 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 + - ENABLE_OPENAI_API=true + - ENABLE_RAG_WEB_SEARCH=true + - RAG_WEB_SEARCH_ENGINE=searxng + - SEARXNG_QUERY_URL=http://searxng:8080/search?q=&format=json + - WEBUI_AUTH=false + depends_on: + ollama: {condition: service_healthy} + + 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: + 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= + 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: + 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: + 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: + 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: + 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: 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: + 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" + +# ============================================================================= +section "Firewall" +# ============================================================================= +if command -v ufw &>/dev/null && [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; then + read -rp " LAN subnet [192.168.1.0/24]: " LAN; LAN="${LAN:-192.168.1.0/24}" + [[ "$LAN" =~ /[0-9]+$ ]] || LAN="${LAN}/24" + for pc in "3000:Open WebUI" "11434:Ollama" "8001:RAG" "8002:MCP" \ + "8000:ChromaDB" "8888:SearXNG" "8181:Kiwix" \ + "3001:Gitea" "2222:Gitea SSH" "9090:InvokeAI" \ + "9000:Portainer" "9443:Portainer S"; do + sudo ufw allow from "$LAN" to any port "${pc%%:*}" proto tcp comment "${pc##*:}" >/dev/null + done + sudo ufw reload >/dev/null; ok "UFW rules set for $LAN"; touch "$BASE/.ufw-done" +fi + +# ============================================================================= +section "Helper Scripts" +# ============================================================================= +cat > "$BASE/start.sh" << STARTSH +#!/bin/bash +cd "$BASE" +docker compose pull --quiet 2>/dev/null +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 (run kiwix_download.sh first)" +echo " Gitea → http://$LOCAL_IP:3001" +echo " RAG → http://$LOCAL_IP:8001/health" +echo " MCP SSE → http://$LOCAL_IP:8002/sse" +echo " Portainer → https://$LOCAL_IP:9443" +echo "" +echo " claude mcp add local http://$LOCAL_IP:8002/sse" +STARTSH +chmod +x "$BASE/start.sh" + +cat > "$BASE/stop.sh" << STOPSH +#!/bin/bash +cd "$BASE" && docker compose down +STOPSH +chmod +x "$BASE/stop.sh" + +cat > "$BASE/status.sh" << 'STATUSSH' +#!/bin/bash +echo "=== GPU ===" && nvidia-smi --query-gpu=name,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 ===" && 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 +STATUSSH +chmod +x "$BASE/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 "Embed model (RAG — required)..." +docker exec ollama ollama pull $EMBED_MODEL + +echo "Fast chat model..." +docker exec ollama ollama pull qwen2.5:7b + +echo "Smart chat model..." +docker exec ollama ollama pull $CHAT_MODEL + +echo "Code model..." +docker exec ollama ollama pull $CODE_MODEL + +echo "Reasoning model (DeepSeek-R1 14B — optional)..." +read -rp "Pull DeepSeek-R1:14b for planning/reasoning? [y/N]: " DR +[[ "\${DR,,}" == "y" ]] && docker exec ollama ollama pull deepseek-r1:14b + +echo "" && docker exec ollama ollama list +PULLSH +chmod +x "$BASE/pull-models.sh" +ok "start.sh stop.sh status.sh pull-models.sh" + +# ============================================================================= +section "Systemd" +# ============================================================================= +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: local-ai.service enabled" + +# ============================================================================= +section "Starting Stack" +# ============================================================================= +cd "$BASE" +info "Pulling images..." +docker compose pull --quiet +docker compose up -d +ok "Stack running" + +if ! $IS_UPDATE && ! $NO_PULL; then + echo "" + read -rp "Pull Ollama models now? (~15-30 min) [Y/n]: " DO_PULL + [[ "${DO_PULL,,}" != "n" ]] && bash "$BASE/pull-models.sh" +fi + +# ============================================================================= +echo "" +echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}${BOLD} Done! GPU: ${VRAM_GB}GB → $TIER${NC}" +echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${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" +echo -e " ${CYAN}Gitea${NC} → http://$LOCAL_IP:3001" +echo -e " ${CYAN}RAG${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}Tokens:${NC} $BASE/.env" +echo -e " ${YELLOW}PDFs:${NC} $BASE/papers/" +echo -e " ${YELLOW}Workspace:${NC} $BASE/workspace/" +echo -e " ${YELLOW}ZIMs:${NC} ./kiwix_download.sh" +echo "" +echo -e " ${YELLOW}Save Claude usage:${NC} use local 14B for boilerplate, docs," +echo " simple fixes. Use Claude Sonnet 4.6 for hard bugs," +echo " multi-file refactoring, architecture decisions." +echo ""