Compare commits
10
Commits
0b1ef264d1
...
bc651d27c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc651d27c4 | ||
|
|
40bb08bcac | ||
|
|
4a440dd465 | ||
|
|
aadca794b4 | ||
|
|
112be30317 | ||
|
|
bb38aefb9d | ||
|
|
47742e62d3 | ||
|
|
d428a91120 | ||
|
|
2d10c968e4 | ||
|
|
cffaa7a072 |
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user