Compare commits

..
10 Commits
Author SHA1 Message Date
Outis bc651d27c4 Update README.md 2026-06-01 12:21:48 -04:00
Outis 40bb08bcac Update README with .env file creation and SearXNG fix
Added instructions for creating .env files and fixed SearXNG setup.
2026-06-01 09:53:30 -04:00
Outis 4a440dd465 Create switching.html 2026-06-01 09:35:48 -04:00
Outis aadca794b4 Add initial HTML template for Local AI Portal 2026-06-01 09:35:07 -04:00
Outis 112be30317 Add Flask app for managing AI stacks 2026-06-01 09:32:30 -04:00
Outis bb38aefb9d Add Dockerfile for Python Flask application 2026-06-01 09:31:31 -04:00
Outis 47742e62d3 Create docker-compose.yml for ai-portal
Added docker-compose.yml for ai-portal service configuration.
2026-06-01 09:30:36 -04:00
Outis d428a91120 Create docker-compose.yml 2026-06-01 09:29:28 -04:00
Outis 2d10c968e4 Rename update.sh to ai-image-gen/update.sh 2026-06-01 09:27:26 -04:00
Outis cffaa7a072 Rename docker-compose.yml to ai-image-gen/docker-compose.yml 2026-06-01 09:26:52 -04:00
9 changed files with 470 additions and 3 deletions
+98 -3
View File
@@ -20,7 +20,7 @@ Portal at `localai.mydomain.com` switches between stacks to share VRAM.
## Directory structure ## Directory structure
``` ```
~/docker homelab-ai/
├── ai-image-gen/ ├── ai-image-gen/
│ ├── docker-compose.yml ← InvokeAI │ ├── docker-compose.yml ← InvokeAI
│ └── archive.sh ← saves Docker images for offline restore │ └── archive.sh ← saves Docker images for offline restore
@@ -83,7 +83,12 @@ mkdir -p ~/docker/ai-image-gen/data/invokeai
mkdir -p ~/docker/ai-llm/data/ollama mkdir -p ~/docker/ai-llm/data/ollama
mkdir -p ~/docker/ai-llm/data/open-webui mkdir -p ~/docker/ai-llm/data/open-webui
mkdir -p ~/docker/ai-llm/data/searxng mkdir -p ~/docker/ai-llm/data/searxng
mkdir -p ~/docker/ai-portal mkdir -p ~/docker/ai-portal/templates
```
### 7. Create .env files
```bash
echo "TZ=America/New_York" | tee ~/docker/ai-llm/.env ~/docker/ai-image-gen/.env ~/docker/ai-portal/.env
``` ```
### 7. Copy files from repo ### 7. Copy files from repo
@@ -120,7 +125,97 @@ docker exec ai-llm-ollama-1 ollama pull llava:7b # vision/screenshots
First model for InvokeAI — go to `images.mydomain.com` → Model Manager → search `sd-1.5-inpainting` → Install. First model for InvokeAI — go to `images.mydomain.com` → Model Manager → search `sd-1.5-inpainting` → Install.
## Open WebUI web search setup ## InvokeAI Usage Guide
### Installing your first model
1. Open `https://images.mydomain.com`
2. Click **Model Manager** (left sidebar)
3. Click **HuggingFace**
4. Search: `runwayml/stable-diffusion-inpainting`
5. Click Install — downloads automatically
### Text to Image (like ChatGPT image generation)
1. Click the **canvas icon** (left sidebar)
2. Make sure model is selected at top (e.g. `sd-1.5-inpainting`)
3. Type your prompt in the prompt box
4. Click **Invoke**
5. Results appear in the gallery (right side)
Key settings to learn:
- **Steps** — higher = more refined, slower (20-30 is good)
- **CFG Scale** — how closely to follow the prompt (7-9 is good)
- **Seed** — lock it to reproduce the exact same image
### Inpainting (fixing details in an existing image)
1. Open an image from the gallery or upload one
2. Click **Send to Canvas**
3. Select the **Brush** tool
4. Paint a mask over the area you want to change (face, hand, background)
5. Type what you want in that area in the prompt
6. Adjust **Denoising Strength** — lower (0.3-0.5) = subtle, higher (0.7+) = more creative
7. Click **Invoke**
8. The masked area is regenerated, rest of image stays intact
Tips:
- Use **Inpaint at Full Resolution** for face/hand details
- Keep denoising low (0.3-0.4) for seamless blending
- Mask blur of 4-8px softens the edge between inpainted and original
### Using an image as a base (img2img)
1. Upload or select an image from gallery
2. Click **Send to Canvas**
3. Change mode to **Image to Image**
4. Type your prompt describing what you want
5. Adjust **Denoising Strength**:
- Low (0.3) = keeps original, subtle changes
- High (0.8) = major transformation
6. Click **Invoke**
### Consistent character images (for product lines)
To maintain a consistent character across many images:
**Method 1 — Seed locking:**
- Generate until you get the character you want
- Note the seed number
- Lock the seed — same seed = same character base every time
- Change only the prompt details (pose, background, clothing)
**Method 2 — Reference image + low denoising:**
- Use your established character image as the base
- Set denoising to 0.3-0.4
- Change prompt to describe the new scene/pose
- Character features stay consistent, context changes
**Method 3 — LoRA models:**
- Train or download a LoRA for your specific character
- Load the LoRA in Model Manager
- Add the LoRA trigger word to your prompt
- Most consistent method for commercial product lines
- LoRAs available at civitai.com
### Workflow for best results
1. Generate several variations (change seed each time)
2. Pick the best base image
3. Lock the seed
4. Inpaint problem areas (faces, hands, backgrounds)
5. Use img2img for scene variations with same character
6. Upscale final image for print quality
SearXNG's wikidata engine fails on startup due to external blocks. Disable it:
```bash
# Find the line number
sudo grep -n "name: wikidata" ~/docker/ai-llm/data/searxng/settings.yml
# Add inactive: true after the engine line (replace 771 with actual line number)
sudo sed -i '771a\ inactive: true' ~/docker/ai-llm/data/searxng/settings.yml
# Verify
sudo sed -n '769,774p' ~/docker/ai-llm/data/searxng/settings.yml
# Restart
docker compose -f ~/docker/ai-llm/docker-compose.yml restart searxng
```
In Open WebUI Admin Panel → Settings → Web Search: In Open WebUI Admin Panel → Settings → Web Search:
- Engine: Brave - Engine: Brave
- API Key: get free key at `api.search.brave.com` (free $5/month credits, set spending cap to Free) - API Key: get free key at `api.search.brave.com` (free $5/month credits, set spending cap to Free)
+74
View File
@@ -0,0 +1,74 @@
# ~/docker/ai-llm/docker-compose.yml
#
# ═══════════════════════════════════════════════════════════════════════════════
# USAGE
# ═══════════════════════════════════════════════════════════════════════════════
#
# Before starting this stack, stop InvokeAI via the portal.
#
# Start:
# cd ~/docker/ai-llm && docker compose up -d
#
# Pull models (run once per model):
# docker exec ai-llm-ollama-1 ollama pull qwen2.5-coder:7b
# docker exec ai-llm-ollama-1 ollama pull qwen2.5:7b
# docker exec ai-llm-ollama-1 ollama pull llava:7b
#
# Open WebUI → https://chat.mydomain.com
#
# After starting, enable web search in Open WebUI:
# Settings → Web Search → Enable → SearXNG → http://searxng:8080
# ═══════════════════════════════════════════════════════════════════════════════
services:
# ── Ollama ───────────────────────────────────────────────────────────────────
ollama:
image: ollama/ollama:latest
pull_policy: missing
environment:
- TZ=America/New_York
volumes:
- ./data/ollama:/root/.ollama
ports:
- "11434:11434"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: unless-stopped
# ── Open WebUI ───────────────────────────────────────────────────────────────
open-webui:
image: ghcr.io/open-webui/open-webui:user0
pull_policy: missing
volumes:
- ./data/open-webui:/app/backend/data
ports:
- "3000:8080"
environment:
- TZ=America/New_York
- OLLAMA_BASE_URL=http://ollama:11434
- SEARXNG_QUERY_URL=http://searxng:8080/search?q=<query>&format=json
depends_on:
- ollama
- searxng
restart: unless-stopped
# ── SearXNG ──────────────────────────────────────────────────────────────────
# Privacy-respecting meta search — no API key needed
# Open WebUI Settings → Web Search → SearXNG → http://searxng:8080
searxng:
image: searxng/searxng:latest
pull_policy: missing
volumes:
- ./data/searxng:/etc/searxng
environment:
- TZ=America/New_York
- SEARXNG_BASE_URL=http://searxng:8080
expose:
- 8080
restart: unless-stopped
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
# Install docker compose CLI so the app can control other stacks
RUN apt-get update && apt-get install -y curl && \
curl -fsSL https://get.docker.com | sh && \
pip install flask && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY app.py .
COPY templates/ templates/
CMD ["python", "app.py"]
+117
View File
@@ -0,0 +1,117 @@
import os
import subprocess
import threading
from flask import Flask, render_template, redirect, jsonify
app = Flask(__name__)
IMAGE_STACK = os.environ.get("IMAGE_STACK", "/docker/ai-image-gen")
LLM_STACK = os.environ.get("LLM_STACK", "/docker/ai-llm")
# Track current active stack and swap status
state = {
"active": None, # "images" | "chat" | None
"swapping": False,
"message": "No stack active"
}
def run_compose(stack_dir, command):
"""Run a docker compose command in a stack directory."""
subprocess.run(
["docker", "compose", "-f", f"{stack_dir}/docker-compose.yml"] + command,
capture_output=True
)
def is_stack_running(stack_dir):
"""Check if the GPU container for this stack is running."""
container = "ai-image-gen-invokeai-1" if "image" in stack_dir else "ai-llm-ollama-1"
result = subprocess.run(
["docker", "inspect", "--format", "{{.State.Running}}", container],
capture_output=True,
text=True
)
return result.stdout.strip() == "true"
def do_swap(target):
"""Stop the GPU-heavy container of inactive stack, start target."""
state["swapping"] = True
if target == "images":
state["message"] = "Stopping Ollama to free VRAM..."
subprocess.run(["docker", "stop", "ai-llm-ollama-1"], capture_output=True)
state["message"] = "Starting InvokeAI..."
subprocess.run(
["docker", "compose", "-f", f"{IMAGE_STACK}/docker-compose.yml", "up", "-d"],
capture_output=True
)
else:
state["message"] = "Stopping InvokeAI to free VRAM..."
subprocess.run(["docker", "stop", "ai-image-gen-invokeai-1"], capture_output=True)
state["message"] = "Starting Ollama..."
subprocess.run(["docker", "start", "ai-llm-ollama-1"], capture_output=True)
state["active"] = target
state["swapping"] = False
state["message"] = f"{target.capitalize()} is ready."
@app.route("/")
@app.route("/portal")
def portal():
image_running = is_stack_running(IMAGE_STACK)
llm_running = is_stack_running(LLM_STACK)
return render_template(
"portal.html",
active=state["active"],
swapping=state["swapping"],
message=state["message"],
image_running=image_running,
llm_running=llm_running
)
@app.route("/switch/<target>")
def switch(target):
if target not in ("images", "chat"):
return "Invalid target", 400
if state["swapping"]:
return redirect("/portal")
# Warn if same stack requested
if state["active"] == target:
urls = {"chat": "https://chat.mydomain.com", "images": "https://images.mydomain.com"}
return redirect(urls[target])
# Run swap in background so browser gets immediate response
thread = threading.Thread(target=do_swap, args=(target,))
thread.daemon = True
thread.start()
return render_template(
"switching.html",
target=target,
message=f"Switching to {target}... this takes about 10 seconds."
)
@app.route("/chat")
def chat():
return redirect(URLS["chat"])
@app.route("/images")
def images():
return redirect(URLS["images"])
@app.route("/status")
def status():
"""Polled by the switching page to know when swap is done."""
return jsonify(state)
if __name__ == "__main__":
# Detect what's already running on startup
if is_stack_running(IMAGE_STACK):
state["active"] = "images"
state["message"] = "Images stack is active."
elif is_stack_running(LLM_STACK):
state["active"] = "chat"
state["message"] = "Chat stack is active."
app.run(host="0.0.0.0", port=8080)
+26
View File
@@ -0,0 +1,26 @@
# ~/docker/ai-portal/docker-compose.yml
#
# Landing page and stack swap controller.
# This stays running permanently — it is lightweight (no GPU).
#
# Start:
# cd ~/docker/ai-portal && docker compose up -d
#
# Portal → http://192.0.2.1:8080
# Caddy proxies localai.mydomain.com → here
services:
portal:
build: .
ports:
- "8080:8080"
volumes:
# Needs access to Docker socket to start/stop other stacks
- /var/run/docker.sock:/var/run/docker.sock
# Needs access to the other compose files
- /home/user/docker:/docker
environment:
- IMAGE_STACK=/docker/ai-image-gen
- LLM_STACK=/docker/ai-llm
restart: unless-stopped
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Local AI Portal</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 16px;
padding: 2.5rem;
width: 420px;
text-align: center;
}
h1 { font-size: 1.4rem; font-weight: 500; margin-bottom: 0.4rem; }
.status { font-size: 0.85rem; color: #888; margin-bottom: 2rem; }
.buttons { display: flex; gap: 1rem; }
.btn {
flex: 1;
padding: 1rem;
border-radius: 12px;
border: 1px solid #333;
background: #222;
color: #e0e0e0;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
display: block;
transition: background 0.15s;
}
.btn:hover { background: #2a2a2a; }
.btn.active { border-color: #4a9eff; background: #1a2a3a; color: #4a9eff; }
.btn.active-green { border-color: #4aff8a; background: #1a3a2a; color: #4aff8a; }
.btn span { display: block; font-size: 0.75rem; color: #666; margin-top: 0.3rem; }
.btn.active span, .btn.active-green span { color: inherit; opacity: 0.7; }
.message { margin-top: 1.5rem; font-size: 0.8rem; color: #555; }
{% if swapping %}
.btn { pointer-events: none; opacity: 0.5; }
{% endif %}
</style>
</head>
<body>
<div class="card">
<h1>Local AI</h1>
<p class="status">{{ message }}</p>
<div class="buttons">
<a href="/switch/chat"
class="btn {% if active == 'chat' %}active{% endif %}">
💬 Chat
<span>{% if active == 'chat' %}active{% else %}Open WebUI + Ollama{% endif %}</span>
</a>
<a href="/switch/images"
class="btn {% if active == 'images' %}active-green{% endif %}">
🎨 Images
<span>{% if active == 'images' %}active{% else %}InvokeAI{% endif %}</span>
</a>
</div>
<p class="message">{{ message }}</p>
</div>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Switching...</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 16px;
padding: 2.5rem;
width: 420px;
text-align: center;
}
h1 { font-size: 1.2rem; font-weight: 500; margin-bottom: 1rem; }
.message { color: #888; font-size: 0.9rem; margin-bottom: 1.5rem; }
.spinner {
width: 32px; height: 32px;
border: 3px solid #333;
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status { margin-top: 1rem; font-size: 0.8rem; color: #555; }
</style>
</head>
<body>
<div class="card">
<h1>Switching to {{ target }}...</h1>
<p class="message">{{ message }}</p>
<div class="spinner"></div>
<p class="status" id="status">Please wait...</p>
</div>
<script>
const target = "{{ target }}";
function poll() {
fetch("/status")
.then(r => r.json())
.then(data => {
document.getElementById("status").textContent = data.message;
if (!data.swapping && data.active === target) {
const urls = { chat: "https://chat.mydomain.com", images: "https://images.mydomain.com" };
// Extra delay for images stack — InvokeAI takes longer to be ready
const delay = target === "images" ? 8000 : 2000;
setTimeout(() => { window.location.href = urls[target]; }, delay);
} else {
setTimeout(poll, 1500);
}
})
.catch(() => setTimeout(poll, 2000));
}
poll();
</script>
</body>
</html>