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:
@@ -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
|
||||
```
|
||||
@@ -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
37
ai/facefusion.yaml
Normal 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
|
||||
@@ -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())
|
||||
4
arty.yml
4
arty.yml
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user