feat: integrate Facefusion into AI stack

Added Facefusion face swapping service to the AI stack:

**Configuration:**
- URL: https://facefusion.ai.pivoine.art
- Image: facefusion/facefusion:3.5.0-cpu
- Port: 7865
- Container: ai_facefusion
- Volume: ai_facefusion_data
- HTTP Basic Auth protection
- CPU execution mode (GPU when available)

**Changes:**
- Added facefusion service to ai/compose.yaml
- Added AI_FACEFUSION_* env vars to arty.yml
- Created ai_facefusion_data volume
- Removed old standalone facefusion stack
- Removed ai/README-export.md and ai/webui-export.py

Facefusion will run on CPU until GPU server is available.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 09:36:52 +01:00
parent 3ddc76e213
commit 8b77f92028
5 changed files with 77 additions and 370 deletions

View File

@@ -1,124 +0,0 @@
# Open WebUI Code Export
Simple Python script to export code blocks from Open WebUI chat conversations to your local disk using the REST API.
## Features
- Export code blocks from specific chats or all chats
- Automatically detects language and saves with proper file extension
- Organizes files by chat title
- Saves metadata (timestamp, role, language) alongside code
- No Docker modifications needed - uses REST API
## Requirements
```bash
pip install requests
```
## Usage
### Export specific chat
First, get your chat ID from the URL when viewing a chat in Open WebUI:
- URL format: `https://ai.pivoine.art/c/<chat_id>`
- Example: `https://ai.pivoine.art/c/abc123def456`
```bash
# Export single chat
python ai/webui-export.py --chat-id abc123def456 --output-dir ./my-code
# Export from remote server
python ai/webui-export.py --base-url https://ai.pivoine.art --chat-id abc123def456 --output-dir ./my-code
```
### Export all chats
```bash
# Export all chats
python ai/webui-export.py --all --output-dir ./all-code
# Export from remote server
python ai/webui-export.py --base-url https://ai.pivoine.art --all --output-dir ./all-code
```
### With authentication (if needed)
```bash
# If API key authentication is required
python ai/webui-export.py --api-key your-api-key --all --output-dir ./my-code
```
## Output Structure
```
webui-exports/
├── My Python Project/
│ ├── 000-00.py # First code block from first message
│ ├── 000-00.py.meta.json # Metadata
│ ├── 001-00.py # First code block from second message
│ └── 001-00.py.meta.json
└── Database Schema Design/
├── 000-00.sql
└── 000-00.sql.meta.json
```
## Remote Usage (VPS)
To export code from Open WebUI running on your VPS:
```bash
# SSH tunnel to access the API
ssh -L 8080:localhost:8080 root@vps
# In another terminal, export chats
python ai/webui-export.py --all --output-dir ~/Projects/webui-exports
```
Or directly via the public URL:
```bash
python ai/webui-export.py --base-url https://ai.pivoine.art --all --output-dir ~/Projects/webui-exports
```
## Supported Languages
The script automatically detects and saves with proper extensions:
- Python (.py)
- JavaScript/TypeScript (.js/.ts)
- Shell scripts (.sh)
- SQL (.sql)
- HTML/CSS (.html/.css)
- JSON/YAML (.json/.yaml)
- And many more...
## Tips
1. **Find chat IDs**: Look at the URL bar when viewing a conversation
2. **Regular exports**: Run `--all` periodically to backup your code
3. **Integration**: Add to cron job for automatic backups
4. **Local testing**: Run locally first with `--base-url http://localhost:8080`
## Example Workflow
```bash
# 1. Have a conversation with Claude in Open WebUI where code is generated
# 2. Note the chat ID from URL (or use --all)
# 3. Export the code
python ai/webui-export.py --chat-id abc123 --output-dir ~/Projects/new-feature
# 4. The code is now in ~/Projects/new-feature/Chat Title/000-00.py
# 5. Review, modify, and use as needed
```
## Automation
Add to crontab for daily exports:
```bash
# Edit crontab
crontab -e
# Add daily export at 2 AM
0 2 * * * cd ~/Projects/docker-compose && python ai/webui-export.py --base-url https://ai.pivoine.art --all --output-dir ~/Projects/webui-exports
```

View File

@@ -146,6 +146,40 @@ services:
# Watchtower
- 'com.centurylinklabs.watchtower.enable=${WATCHTOWER_LABEL_ENABLE}'
# Facefusion - AI face swapping and enhancement
facefusion:
image: ${AI_FACEFUSION_IMAGE:-facefusion/facefusion:3.5.0-cpu}
container_name: ${AI_COMPOSE_PROJECT_NAME}_facefusion
restart: unless-stopped
environment:
TZ: ${TIMEZONE:-Europe/Berlin}
# Force CPU execution on VPS (no GPU available yet)
FACEFUSION_EXECUTION_PROVIDERS: ${AI_FACEFUSION_EXECUTION_PROVIDERS:-cpu}
volumes:
- ai_facefusion_data:/workspace
networks:
- compose_network
labels:
- 'traefik.enable=${AI_FACEFUSION_TRAEFIK_ENABLED}'
# HTTP Basic Auth middleware
- 'traefik.http.middlewares.${AI_COMPOSE_PROJECT_NAME}-facefusion-auth.basicauth.users=${AUTH_USERS}'
# HTTP to HTTPS redirect
- 'traefik.http.middlewares.${AI_COMPOSE_PROJECT_NAME}-facefusion-redirect-web-secure.redirectscheme.scheme=https'
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web.middlewares=${AI_COMPOSE_PROJECT_NAME}-facefusion-redirect-web-secure'
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web.rule=Host(`${AI_FACEFUSION_TRAEFIK_HOST}`)'
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web.entrypoints=web'
# HTTPS router with auth
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure.rule=Host(`${AI_FACEFUSION_TRAEFIK_HOST}`)'
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure.tls.certresolver=resolver'
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure.entrypoints=web-secure'
- 'traefik.http.middlewares.${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure-compress.compress=true'
- 'traefik.http.routers.${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure.middlewares=${AI_COMPOSE_PROJECT_NAME}-facefusion-auth,${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure-compress,security-headers@file'
# Service
- 'traefik.http.services.${AI_COMPOSE_PROJECT_NAME}-facefusion-web-secure.loadbalancer.server.port=7865'
- 'traefik.docker.network=${NETWORK_NAME}'
# Watchtower
- 'com.centurylinklabs.watchtower.enable=${WATCHTOWER_LABEL_ENABLE}'
volumes:
ai_postgres_data:
name: ${AI_COMPOSE_PROJECT_NAME}_postgres_data
@@ -153,3 +187,5 @@ volumes:
name: ${AI_COMPOSE_PROJECT_NAME}_webui_data
ai_crawl4ai_data:
name: ${AI_COMPOSE_PROJECT_NAME}_crawl4ai_data
ai_facefusion_data:
name: ${AI_COMPOSE_PROJECT_NAME}_facefusion_data

37
ai/facefusion.yaml Normal file
View File

@@ -0,0 +1,37 @@
services:
facefusion:
image: ${FACEFUSION_IMAGE:-facefusion/facefusion:3.5.0-cpu}
container_name: ${FACEFUSION_COMPOSE_PROJECT_NAME}_app
restart: unless-stopped
environment:
TZ: ${TIMEZONE:-Europe/Berlin}
# Force CPU execution on VPS (no GPU available)
FACEFUSION_EXECUTION_PROVIDERS: ${FACEFUSION_EXECUTION_PROVIDERS:-cpu}
volumes:
- facefusion_data:/workspace
networks:
- compose_network
labels:
- 'traefik.enable=${FACEFUSION_TRAEFIK_ENABLED}'
# HTTP Basic Auth middleware
- 'traefik.http.middlewares.${FACEFUSION_COMPOSE_PROJECT_NAME}-auth.basicauth.users=${AUTH_USERS}'
# HTTP to HTTPS redirect
- 'traefik.http.middlewares.${FACEFUSION_COMPOSE_PROJECT_NAME}-redirect-web-secure.redirectscheme.scheme=https'
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web.middlewares=${FACEFUSION_COMPOSE_PROJECT_NAME}-redirect-web-secure'
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web.rule=Host(`${FACEFUSION_TRAEFIK_HOST}`)'
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web.entrypoints=web'
# HTTPS router with auth
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure.rule=Host(`${FACEFUSION_TRAEFIK_HOST}`)'
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure.tls.certresolver=resolver'
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure.entrypoints=web-secure'
- 'traefik.http.middlewares.${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure-compress.compress=true'
- 'traefik.http.routers.${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure.middlewares=${FACEFUSION_COMPOSE_PROJECT_NAME}-auth,${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure-compress,security-headers@file'
# Service
- 'traefik.http.services.${FACEFUSION_COMPOSE_PROJECT_NAME}-web-secure.loadbalancer.server.port=7860'
- 'traefik.docker.network=${NETWORK_NAME}'
# Watchtower
- 'com.centurylinklabs.watchtower.enable=${WATCHTOWER_LABEL_ENABLE}'
volumes:
facefusion_data:
name: ${FACEFUSION_COMPOSE_PROJECT_NAME}_data

View File

@@ -1,246 +0,0 @@
#!/usr/bin/env python3
"""
Export code blocks from Open WebUI chat conversations to local disk.
Usage:
# Export specific chat
python webui-export.py --chat-id <chat_id> --output-dir ./output
# Export all recent chats
python webui-export.py --all --output-dir ./output
# Watch for new messages and auto-export
python webui-export.py --watch --output-dir ./output
"""
import argparse
import json
import os
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import requests
class OpenWebUIExporter:
"""Export code from Open WebUI chats via REST API."""
def __init__(self, base_url: str = "http://localhost:8080", api_key: Optional[str] = None):
"""
Initialize exporter.
:param base_url: Open WebUI base URL (default: http://localhost:8080)
:param api_key: Optional API key for authentication
"""
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/api/v1"
self.session = requests.Session()
if api_key:
self.session.headers["Authorization"] = f"Bearer {api_key}"
def get_chats(self) -> List[Dict]:
"""Retrieve all user chats."""
response = self.session.get(f"{self.api_url}/chats/all")
response.raise_for_status()
return response.json()
def get_chat(self, chat_id: str) -> Dict:
"""Retrieve specific chat by ID."""
response = self.session.get(f"{self.api_url}/chats/{chat_id}")
response.raise_for_status()
return response.json()
def extract_code_blocks(self, chat: Dict) -> List[Dict]:
"""
Extract code blocks from chat messages.
:param chat: Chat object with messages
:return: List of code blocks with metadata
"""
code_blocks = []
messages = chat.get("chat", {}).get("messages", [])
for idx, message in enumerate(messages):
role = message.get("role", "unknown")
content = message.get("content", "")
# Find code blocks in markdown (```language\ncode\n```)
pattern = r"```(\w*)\n(.*?)```"
matches = re.findall(pattern, content, re.DOTALL)
for match_idx, (language, code) in enumerate(matches):
code_blocks.append({
"chat_id": chat.get("id"),
"chat_title": chat.get("title", "Untitled"),
"message_index": idx,
"role": role,
"language": language or "txt",
"code": code.strip(),
"block_index": match_idx,
"timestamp": message.get("timestamp", time.time()),
})
return code_blocks
def save_code_blocks(self, code_blocks: List[Dict], output_dir: Path):
"""
Save code blocks to disk.
:param code_blocks: List of code blocks with metadata
:param output_dir: Output directory
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
for block in code_blocks:
# Create subdirectory per chat
chat_dir = output_dir / self._sanitize_filename(block["chat_title"])
chat_dir.mkdir(exist_ok=True)
# Determine file extension
ext = self._get_extension(block["language"])
# Create filename: message_index-block_index.ext
filename = f"{block['message_index']:03d}-{block['block_index']:02d}{ext}"
filepath = chat_dir / filename
# Save code
with open(filepath, "w") as f:
f.write(block["code"])
# Save metadata
meta_filepath = filepath.with_suffix(filepath.suffix + ".meta.json")
with open(meta_filepath, "w") as f:
json.dump({
"chat_id": block["chat_id"],
"chat_title": block["chat_title"],
"message_index": block["message_index"],
"role": block["role"],
"language": block["language"],
"timestamp": block["timestamp"],
"timestamp_human": datetime.fromtimestamp(block["timestamp"]).isoformat(),
}, f, indent=2)
print(f"✓ Saved: {filepath}")
def export_chat(self, chat_id: str, output_dir: Path):
"""Export code blocks from specific chat."""
print(f"Fetching chat {chat_id}...")
chat = self.get_chat(chat_id)
code_blocks = self.extract_code_blocks(chat)
print(f"Found {len(code_blocks)} code blocks")
if code_blocks:
self.save_code_blocks(code_blocks, output_dir)
print(f"✓ Exported {len(code_blocks)} code blocks to {output_dir}")
else:
print("No code blocks found in chat")
def export_all_chats(self, output_dir: Path):
"""Export code blocks from all chats."""
print("Fetching all chats...")
chats = self.get_chats()
print(f"Found {len(chats)} chats")
total_blocks = 0
for chat in chats:
code_blocks = self.extract_code_blocks(chat)
if code_blocks:
self.save_code_blocks(code_blocks, output_dir)
total_blocks += len(code_blocks)
print(f"✓ Exported {total_blocks} code blocks from {len(chats)} chats to {output_dir}")
@staticmethod
def _sanitize_filename(name: str) -> str:
"""Sanitize filename by removing invalid characters."""
# Remove or replace invalid characters
name = re.sub(r'[<>:"/\\|?*]', '_', name)
# Limit length
return name[:100]
@staticmethod
def _get_extension(language: str) -> str:
"""Get file extension for language."""
extensions = {
"python": ".py",
"javascript": ".js",
"typescript": ".ts",
"java": ".java",
"c": ".c",
"cpp": ".cpp",
"csharp": ".cs",
"go": ".go",
"rust": ".rs",
"ruby": ".rb",
"php": ".php",
"swift": ".swift",
"kotlin": ".kt",
"bash": ".sh",
"shell": ".sh",
"sql": ".sql",
"html": ".html",
"css": ".css",
"json": ".json",
"yaml": ".yaml",
"yml": ".yml",
"xml": ".xml",
"markdown": ".md",
"md": ".md",
}
return extensions.get(language.lower(), ".txt")
def main():
parser = argparse.ArgumentParser(
description="Export code blocks from Open WebUI chats"
)
parser.add_argument(
"--base-url",
default="http://localhost:8080",
help="Open WebUI base URL (default: http://localhost:8080)",
)
parser.add_argument(
"--api-key",
help="Optional API key for authentication",
)
parser.add_argument(
"--chat-id",
help="Export specific chat by ID",
)
parser.add_argument(
"--all",
action="store_true",
help="Export all chats",
)
parser.add_argument(
"--output-dir",
default="./webui-exports",
help="Output directory (default: ./webui-exports)",
)
args = parser.parse_args()
exporter = OpenWebUIExporter(base_url=args.base_url, api_key=args.api_key)
output_dir = Path(args.output_dir)
if args.chat_id:
exporter.export_chat(args.chat_id, output_dir)
elif args.all:
exporter.export_all_chats(output_dir)
else:
parser.print_help()
print("\nError: Specify either --chat-id or --all")
return 1
return 0
if __name__ == "__main__":
exit(main())

View File

@@ -170,6 +170,10 @@ envs:
AI_POSTGRES_IMAGE: pgvector/pgvector:pg16
AI_WEBUI_IMAGE: ghcr.io/open-webui/open-webui:main
AI_CRAWL4AI_IMAGE: unclecode/crawl4ai:latest
AI_FACEFUSION_IMAGE: facefusion/facefusion:3.5.0-cpu
AI_FACEFUSION_TRAEFIK_ENABLED: true
AI_FACEFUSION_TRAEFIK_HOST: facefusion.ai.pivoine.art
AI_FACEFUSION_EXECUTION_PROVIDERS: cpu
AI_TRAEFIK_HOST: ai.pivoine.art
AI_DB_USER: ai
AI_DB_NAME: openwebui