- New web UI with OpenWebUI-like interface using Tailwind CSS - SQLite-based authentication with session management - User registration, login, and profile pages - Chat interface with conversation history - Streaming responses with visible thinking phase - File attachments support - User document uploads in profile - Rate limiting (5 requests/day for free users) - Admin panel user management with promote/demote/delete - Custom color theme matching balloon logo design - Compatible with Nuitka build system
460 lines
22 KiB
HTML
Executable File
460 lines
22 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ComfyUI - MOXIE Admin</title>
|
|
<link rel="stylesheet" href="/{{ settings.admin_path }}/static/admin.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-brand">MOXIE Admin</div>
|
|
<div class="nav-links">
|
|
<a href="/{{ settings.admin_path }}/">Dashboard</a>
|
|
<a href="/{{ settings.admin_path }}/endpoints">Endpoints</a>
|
|
<a href="/{{ settings.admin_path }}/documents">Documents</a>
|
|
<a href="/{{ settings.admin_path }}/comfyui">ComfyUI</a>
|
|
<a href="/{{ settings.admin_path }}/users">Users</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="container">
|
|
<h1>ComfyUI Configuration</h1>
|
|
|
|
<p class="help-text">
|
|
Configure ComfyUI for image, video, and audio generation.
|
|
Upload workflows in <strong>API Format</strong> (enable Dev Mode in ComfyUI, then use "Save (API Format)").
|
|
</p>
|
|
|
|
<!-- Connection Settings -->
|
|
<section class="config-section">
|
|
<h2>Connection Settings</h2>
|
|
<form id="connection-form" class="form-inline">
|
|
<div class="form-group">
|
|
<label for="comfyui_host">ComfyUI URL</label>
|
|
<input type="text" id="comfyui_host" name="comfyui_host"
|
|
value="{{ config.get('comfyui_host', 'http://127.0.0.1:8188') }}"
|
|
placeholder="http://127.0.0.1:8188" style="width: 300px;">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm">Save & Test</button>
|
|
</form>
|
|
<div id="connection-status" class="status-indicator checking" style="margin-top: 0.5rem;">Checking...</div>
|
|
</section>
|
|
|
|
<!-- Workflow Tabs -->
|
|
<section class="workflow-section">
|
|
<div class="workflow-tabs">
|
|
<button class="tab-btn active" data-tab="image">Image</button>
|
|
<button class="tab-btn" data-tab="video">Video</button>
|
|
<button class="tab-btn" data-tab="audio">Audio</button>
|
|
</div>
|
|
|
|
<!-- Image Workflow Tab -->
|
|
<div id="image-tab" class="tab-content active">
|
|
<div class="workflow-header">
|
|
<h3>Image Generation Workflow</h3>
|
|
{% if workflows.image %}
|
|
<span class="badge success">Configured</span>
|
|
{% else %}
|
|
<span class="badge warning">Not Configured</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<form id="image-workflow-form" class="workflow-form">
|
|
<!-- Workflow Upload -->
|
|
<div class="form-group">
|
|
<label>Workflow JSON (API Format)</label>
|
|
<div class="file-upload">
|
|
<input type="file" id="image-workflow-file" accept=".json">
|
|
<button type="button" class="btn btn-sm" onclick="uploadWorkflow('image')">Upload</button>
|
|
{% if workflows.image %}
|
|
<button type="button" class="btn btn-sm" onclick="viewWorkflow('image')">View JSON</button>
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="deleteWorkflow('image')">Delete</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Node ID Mappings -->
|
|
<div class="node-mappings">
|
|
<h4>Node ID Mappings</h4>
|
|
<p class="help-text">Map the node IDs from your workflow. Find these in ComfyUI or the workflow JSON.</p>
|
|
|
|
<div class="mapping-grid">
|
|
<div class="form-group">
|
|
<label for="image_prompt_node">Prompt Node ID</label>
|
|
<input type="text" id="image_prompt_node" name="image_prompt_node"
|
|
value="{{ config.get('image_prompt_node', '') }}"
|
|
placeholder="e.g., 6">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_negative_prompt_node">Negative Prompt Node ID</label>
|
|
<input type="text" id="image_negative_prompt_node" name="image_negative_prompt_node"
|
|
value="{{ config.get('image_negative_prompt_node', '') }}"
|
|
placeholder="e.g., 7">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_seed_node">Seed Node ID</label>
|
|
<input type="text" id="image_seed_node" name="image_seed_node"
|
|
value="{{ config.get('image_seed_node', '') }}"
|
|
placeholder="e.g., 3">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_steps_node">Steps Node ID</label>
|
|
<input type="text" id="image_steps_node" name="image_steps_node"
|
|
value="{{ config.get('image_steps_node', '') }}"
|
|
placeholder="e.g., 3">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_width_node">Width Node ID</label>
|
|
<input type="text" id="image_width_node" name="image_width_node"
|
|
value="{{ config.get('image_width_node', '') }}"
|
|
placeholder="e.g., 5">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_height_node">Height Node ID</label>
|
|
<input type="text" id="image_height_node" name="image_height_node"
|
|
value="{{ config.get('image_height_node', '') }}"
|
|
placeholder="e.g., 5">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_cfg_node">CFG Scale Node ID</label>
|
|
<input type="text" id="image_cfg_node" name="image_cfg_node"
|
|
value="{{ config.get('image_cfg_node', '') }}"
|
|
placeholder="e.g., 3">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_output_node">Output Node ID</label>
|
|
<input type="text" id="image_output_node" name="image_output_node"
|
|
value="{{ config.get('image_output_node', '') }}"
|
|
placeholder="e.g., 9">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_default_size">Default Size</label>
|
|
<input type="text" id="image_default_size" name="image_default_size"
|
|
value="{{ config.get('image_default_size', '512x512') }}"
|
|
placeholder="512x512">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image_default_steps">Default Steps</label>
|
|
<input type="number" id="image_default_steps" name="image_default_steps"
|
|
value="{{ config.get('image_default_steps', 20) }}"
|
|
placeholder="20">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary">Save Image Settings</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Video Workflow Tab -->
|
|
<div id="video-tab" class="tab-content">
|
|
<div class="workflow-header">
|
|
<h3>Video Generation Workflow</h3>
|
|
{% if workflows.video %}
|
|
<span class="badge success">Configured</span>
|
|
{% else %}
|
|
<span class="badge warning">Not Configured</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<form id="video-workflow-form" class="workflow-form">
|
|
<div class="form-group">
|
|
<label>Workflow JSON (API Format)</label>
|
|
<div class="file-upload">
|
|
<input type="file" id="video-workflow-file" accept=".json">
|
|
<button type="button" class="btn btn-sm" onclick="uploadWorkflow('video')">Upload</button>
|
|
{% if workflows.video %}
|
|
<button type="button" class="btn btn-sm" onclick="viewWorkflow('video')">View JSON</button>
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="deleteWorkflow('video')">Delete</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="node-mappings">
|
|
<h4>Node ID Mappings</h4>
|
|
<div class="mapping-grid">
|
|
<div class="form-group">
|
|
<label for="video_prompt_node">Prompt Node ID</label>
|
|
<input type="text" id="video_prompt_node" name="video_prompt_node"
|
|
value="{{ config.get('video_prompt_node', '') }}" placeholder="e.g., 6">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="video_seed_node">Seed Node ID</label>
|
|
<input type="text" id="video_seed_node" name="video_seed_node"
|
|
value="{{ config.get('video_seed_node', '') }}" placeholder="e.g., 3">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="video_frames_node">Frames Node ID</label>
|
|
<input type="text" id="video_frames_node" name="video_frames_node"
|
|
value="{{ config.get('video_frames_node', '') }}" placeholder="e.g., 10">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="video_output_node">Output Node ID</label>
|
|
<input type="text" id="video_output_node" name="video_output_node"
|
|
value="{{ config.get('video_output_node', '') }}" placeholder="e.g., 9">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="video_default_frames">Default Frames</label>
|
|
<input type="number" id="video_default_frames" name="video_default_frames"
|
|
value="{{ config.get('video_default_frames', 24) }}" placeholder="24">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary">Save Video Settings</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Audio Workflow Tab -->
|
|
<div id="audio-tab" class="tab-content">
|
|
<div class="workflow-header">
|
|
<h3>Audio Generation Workflow</h3>
|
|
{% if workflows.audio %}
|
|
<span class="badge success">Configured</span>
|
|
{% else %}
|
|
<span class="badge warning">Not Configured</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<form id="audio-workflow-form" class="workflow-form">
|
|
<div class="form-group">
|
|
<label>Workflow JSON (API Format)</label>
|
|
<div class="file-upload">
|
|
<input type="file" id="audio-workflow-file" accept=".json">
|
|
<button type="button" class="btn btn-sm" onclick="uploadWorkflow('audio')">Upload</button>
|
|
{% if workflows.audio %}
|
|
<button type="button" class="btn btn-sm" onclick="viewWorkflow('audio')">View JSON</button>
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="deleteWorkflow('audio')">Delete</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="node-mappings">
|
|
<h4>Node ID Mappings</h4>
|
|
<div class="mapping-grid">
|
|
<div class="form-group">
|
|
<label for="audio_prompt_node">Prompt Node ID</label>
|
|
<input type="text" id="audio_prompt_node" name="audio_prompt_node"
|
|
value="{{ config.get('audio_prompt_node', '') }}" placeholder="e.g., 6">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="audio_seed_node">Seed Node ID</label>
|
|
<input type="text" id="audio_seed_node" name="audio_seed_node"
|
|
value="{{ config.get('audio_seed_node', '') }}" placeholder="e.g., 3">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="audio_duration_node">Duration Node ID</label>
|
|
<input type="text" id="audio_duration_node" name="audio_duration_node"
|
|
value="{{ config.get('audio_duration_node', '') }}" placeholder="e.g., 5">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="audio_output_node">Output Node ID</label>
|
|
<input type="text" id="audio_output_node" name="audio_output_node"
|
|
value="{{ config.get('audio_output_node', '') }}" placeholder="e.g., 9">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="audio_default_duration">Default Duration (seconds)</label>
|
|
<input type="number" id="audio_default_duration" name="audio_default_duration"
|
|
value="{{ config.get('audio_default_duration', 10) }}" step="0.5" placeholder="10">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary">Save Audio Settings</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="toast" class="toast hidden"></div>
|
|
|
|
<div id="modal" class="modal hidden">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>Workflow JSON</h3>
|
|
<button class="modal-close" onclick="closeModal()">×</button>
|
|
</div>
|
|
<pre id="modal-json"></pre>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
// Tab switching
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById(btn.dataset.tab + '-tab').classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Connection form
|
|
document.getElementById('connection-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target);
|
|
await saveConfig({ comfyui_host: formData.get('comfyui_host') });
|
|
testConnection();
|
|
});
|
|
|
|
// Workflow forms
|
|
['image', 'video', 'audio'].forEach(type => {
|
|
document.getElementById(`${type}-workflow-form`).addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target);
|
|
const data = {};
|
|
formData.forEach((value, key) => data[key] = value);
|
|
await saveConfig(data);
|
|
});
|
|
});
|
|
|
|
// Upload workflow
|
|
async function uploadWorkflow(type) {
|
|
const fileInput = document.getElementById(`${type}-workflow-file`);
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
showToast('Please select a file', 'error');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('workflow_type', type);
|
|
|
|
try {
|
|
const response = await fetch('/{{ settings.admin_path }}/comfyui/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'success') {
|
|
showToast(`Workflow uploaded successfully`, 'success');
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
showToast('Upload failed: ' + (result.detail || 'Unknown error'), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Upload failed', 'error');
|
|
}
|
|
}
|
|
|
|
// View workflow
|
|
async function viewWorkflow(type) {
|
|
try {
|
|
const response = await fetch(`/{{ settings.admin_path }}/comfyui/${type}`);
|
|
const data = await response.json();
|
|
document.getElementById('modal-json').textContent = JSON.stringify(data, null, 2);
|
|
document.getElementById('modal').classList.remove('hidden');
|
|
} catch (error) {
|
|
showToast('Failed to load workflow', 'error');
|
|
}
|
|
}
|
|
|
|
// Delete workflow
|
|
async function deleteWorkflow(type) {
|
|
if (!confirm('Delete this workflow?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/{{ settings.admin_path }}/comfyui/${type}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'success') {
|
|
showToast('Workflow deleted', 'success');
|
|
location.reload();
|
|
}
|
|
} catch (error) {
|
|
showToast('Delete failed', 'error');
|
|
}
|
|
}
|
|
|
|
// Save config
|
|
async function saveConfig(data) {
|
|
try {
|
|
const response = await fetch('/{{ settings.admin_path }}/endpoints', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'success') {
|
|
showToast('Settings saved', 'success');
|
|
} else {
|
|
showToast('Failed to save', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to save', 'error');
|
|
}
|
|
}
|
|
|
|
// Test connection
|
|
async function testConnection() {
|
|
const statusEl = document.getElementById('connection-status');
|
|
statusEl.textContent = 'Testing...';
|
|
statusEl.className = 'status-indicator checking';
|
|
|
|
try {
|
|
const response = await fetch('/{{ settings.admin_path }}/status');
|
|
const data = await response.json();
|
|
|
|
if (data.comfyui === 'connected') {
|
|
statusEl.textContent = 'Connected';
|
|
statusEl.className = 'status-indicator connected';
|
|
} else {
|
|
statusEl.textContent = 'Disconnected';
|
|
statusEl.className = 'status-indicator disconnected';
|
|
}
|
|
} catch (error) {
|
|
statusEl.textContent = 'Error';
|
|
statusEl.className = 'status-indicator disconnected';
|
|
}
|
|
}
|
|
|
|
// Modal
|
|
function closeModal() {
|
|
document.getElementById('modal').classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'modal') closeModal();
|
|
});
|
|
|
|
// Toast
|
|
function showToast(message, type) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = 'toast ' + type;
|
|
setTimeout(() => toast.classList.add('hidden'), 3000);
|
|
}
|
|
|
|
// Initial connection test
|
|
testConnection();
|
|
</script>
|
|
</body>
|
|
</html>
|