Initial commit — ComfyUI Discord bot + web UI

Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI
(React/TS/Vite/Tailwind), ComfyUI integration, generation history DB,
preset manager, workflow inspector, and all supporting modules.

Excluded from tracking: .env, invite_tokens.json, *.db (SQLite),
current-workflow-changes.json, user_settings/, presets/, logs/,
web-static/ (build output), frontend/node_modules/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-02 09:55:48 +07:00
commit 1ed3c9ec4b
82 changed files with 20693 additions and 0 deletions

68
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,68 @@
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './hooks/useAuth'
import Layout from './components/Layout'
import { GenerationProvider } from './context/GenerationContext'
import LoginPage from './pages/LoginPage'
import GeneratePage from './pages/GeneratePage'
import InputImagesPage from './pages/InputImagesPage'
import WorkflowPage from './pages/WorkflowPage'
import PresetsPage from './pages/PresetsPage'
import StatusPage from './pages/StatusPage'
import ServerPage from './pages/ServerPage'
import HistoryPage from './pages/HistoryPage'
import AdminPage from './pages/AdminPage'
import SharePage from './pages/SharePage'
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return <div className="flex items-center justify-center h-screen text-gray-500">Loading...</div>
if (!isAuthenticated) return <Navigate to="/login" replace />
return <>{children}</>
}
function RequireAdmin({ children }: { children: React.ReactNode }) {
const { isAdmin, isLoading } = useAuth()
if (isLoading) return null
if (!isAdmin) return <Navigate to="/generate" replace />
return <>{children}</>
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<GenerationProvider>
<Layout />
</GenerationProvider>
</RequireAuth>
}
>
<Route index element={<Navigate to="/generate" replace />} />
<Route path="generate" element={<GeneratePage />} />
<Route path="inputs" element={<InputImagesPage />} />
<Route path="workflow" element={<WorkflowPage />} />
<Route path="presets" element={<PresetsPage />} />
<Route path="status" element={<StatusPage />} />
<Route path="server" element={<ServerPage />} />
<Route path="history" element={<HistoryPage />} />
<Route
path="admin"
element={
<RequireAdmin>
<AdminPage />
</RequireAdmin>
}
/>
</Route>
<Route path="/share/:token" element={<SharePage />} />
<Route path="*" element={<Navigate to="/generate" replace />} />
</Routes>
</BrowserRouter>
)
}

198
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,198 @@
/** Typed API client for the ComfyUI Bot web API. */
const BASE = '' // same-origin in prod; Vite proxy in dev
async function _fetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
})
if (!res.ok) {
const msg = await res.text().catch(() => res.statusText)
throw new Error(`${res.status}: ${msg}`)
}
return res.json() as Promise<T>
}
// Auth
export const authLogin = (token: string) =>
_fetch<{ label: string; admin: boolean }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ token }),
})
export const authLogout = () =>
_fetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' })
export const authMe = () =>
_fetch<{ label: string; admin: boolean }>('/api/auth/me')
// Admin
export const adminLogin = (password: string) =>
_fetch<{ label: string; admin: boolean }>('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
export const adminListTokens = () =>
_fetch<Array<{ id: string; label: string; admin: boolean; created_at: string }>>('/api/admin/tokens')
export const adminCreateToken = (label: string, admin = false) =>
_fetch<{ token: string; label: string; admin: boolean }>('/api/admin/tokens', {
method: 'POST',
body: JSON.stringify({ label, admin }),
})
export const adminRevokeToken = (id: string) =>
_fetch<{ ok: boolean }>(`/api/admin/tokens/${id}`, { method: 'DELETE' })
// Status
export const getStatus = () => _fetch<Record<string, unknown>>('/api/status')
// State / overrides
export const getState = () => _fetch<Record<string, unknown>>('/api/state')
export const putState = (overrides: Record<string, unknown>) =>
_fetch<Record<string, unknown>>('/api/state', {
method: 'PUT',
body: JSON.stringify(overrides),
})
export const deleteStateKey = (key: string) =>
_fetch<{ ok: boolean }>(`/api/state/${key}`, { method: 'DELETE' })
// Generation
export interface GenerateRequest {
prompt: string
negative_prompt?: string
overrides?: Record<string, unknown>
}
export const generate = (body: GenerateRequest) =>
_fetch<{ queued: boolean; queue_position: number }>('/api/generate', {
method: 'POST',
body: JSON.stringify(body),
})
export interface WorkflowGenRequest {
count?: number
overrides?: Record<string, unknown>
}
export const workflowGen = (body: WorkflowGenRequest) =>
_fetch<{ queued: boolean; count: number; queue_position: number }>('/api/workflow-gen', {
method: 'POST',
body: JSON.stringify(body),
})
// Inputs
export interface InputImage {
id: number
original_message_id: number
bot_reply_id: number | null
channel_id: number
filename: string
is_active: number
active_slot_key: string | null
}
export const listInputs = () => _fetch<InputImage[]>('/api/inputs')
export const uploadInput = (file: File, slotKey = 'input_image') => {
const form = new FormData()
form.append('file', file)
form.append('slot_key', slotKey)
return fetch('/api/inputs', {
method: 'POST',
credentials: 'include',
body: form,
}).then(r => r.json())
}
export const activateInput = (id: number, slotKey = 'input_image') =>
_fetch<{ ok: boolean }>(`/api/inputs/${id}/activate?slot_key=${slotKey}`, { method: 'POST' })
export const deleteInput = (id: number) =>
_fetch<{ ok: boolean }>(`/api/inputs/${id}`, { method: 'DELETE' })
export const getInputImage = (id: number) => `/api/inputs/${id}/image`
export const getInputThumb = (id: number) => `/api/inputs/${id}/thumb`
export const getInputMid = (id: number) => `/api/inputs/${id}/mid`
// Presets
export interface PresetMeta { name: string; owner: string | null; description: string | null }
export const listPresets = () => _fetch<{ presets: PresetMeta[] }>('/api/presets')
export const savePreset = (name: string, description?: string) =>
_fetch<{ ok: boolean }>('/api/presets', { method: 'POST', body: JSON.stringify({ name, description: description ?? null }) })
export const getPreset = (name: string) => _fetch<Record<string, unknown>>(`/api/presets/${name}`)
export const loadPreset = (name: string) =>
_fetch<{ ok: boolean }>(`/api/presets/${name}/load`, { method: 'POST' })
export const deletePreset = (name: string) =>
_fetch<{ ok: boolean }>(`/api/presets/${name}`, { method: 'DELETE' })
export const savePresetFromHistory = (promptId: string, name: string, description?: string) =>
_fetch<{ ok: boolean; name: string }>(`/api/presets/from-history/${promptId}`, {
method: 'POST',
body: JSON.stringify({ name, description: description ?? null }),
})
// Server
export const getServerStatus = () =>
_fetch<{ service_state: string; http_reachable: boolean }>('/api/server/status')
export const serverAction = (action: string) =>
_fetch<{ ok: boolean }>(`/api/server/${action}`, { method: 'POST' })
export const tailLogs = (lines = 100) =>
_fetch<{ lines: string[] }>(`/api/logs/tail?lines=${lines}`)
// History
export const getHistory = (q?: string) =>
_fetch<{ history: Array<Record<string, unknown>> }>(q ? `/api/history?q=${encodeURIComponent(q)}` : '/api/history')
export const createHistoryShare = (promptId: string) =>
_fetch<{ share_token: string }>(`/api/history/${promptId}/share`, { method: 'POST' })
export const revokeHistoryShare = (promptId: string) =>
_fetch<{ ok: boolean }>(`/api/history/${promptId}/share`, { method: 'DELETE' })
export const getShareFileUrl = (token: string, filename: string) =>
`/api/share/${token}/file/${encodeURIComponent(filename)}`
// Workflow
export const getWorkflow = () =>
_fetch<{ loaded: boolean; node_count: number; last_workflow_file: string | null }>('/api/workflow')
export interface NodeInput {
key: string
label: string
input_type: string
current_value: unknown
node_class: string
node_title: string
is_common: boolean
}
export const getWorkflowInputs = () =>
_fetch<{ common: NodeInput[]; advanced: NodeInput[] }>('/api/workflow/inputs')
export const listWorkflowFiles = () =>
_fetch<{ files: string[] }>('/api/workflow/files')
export const uploadWorkflow = (file: File) => {
const form = new FormData()
form.append('file', file)
return fetch('/api/workflow/upload', {
method: 'POST',
credentials: 'include',
body: form,
}).then(r => r.json())
}
export const loadWorkflow = (filename: string) => {
const form = new FormData()
form.append('filename', filename)
return fetch('/api/workflow/load', {
method: 'POST',
credentials: 'include',
body: form,
}).then(r => r.json())
}
export const getModels = (type: 'checkpoints' | 'loras') =>
_fetch<{ type: string; models: string[] }>(`/api/workflow/models?type=${type}`)

View File

@@ -0,0 +1,329 @@
import React, { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getWorkflowInputs,
getModels,
putState,
deleteStateKey,
getState,
NodeInput,
activateInput,
listInputs,
getInputImage,
getInputThumb,
getInputMid,
} from '../api/client'
import LazyImage from './LazyImage'
interface Props {
/** Called when the Generate button is clicked with the current overrides */
onGenerate: (overrides: Record<string, unknown>, count: number) => void
/** Live seed from WS generation_complete event */
lastSeed?: number | null
generating?: boolean
/** The authenticated user's label (used to find their active input image) */
userLabel?: string
}
export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating, userLabel }: Props) {
const qc = useQueryClient()
const { data: inputsData, isLoading: inputsLoading } = useQuery({
queryKey: ['workflow', 'inputs'],
queryFn: getWorkflowInputs,
})
const { data: stateData } = useQuery({
queryKey: ['state'],
queryFn: getState,
})
const { data: checkpoints } = useQuery({
queryKey: ['models', 'checkpoints'],
queryFn: () => getModels('checkpoints'),
staleTime: 60_000,
})
const { data: loras } = useQuery({
queryKey: ['models', 'loras'],
queryFn: () => getModels('loras'),
staleTime: 60_000,
})
const { data: inputImages } = useQuery({
queryKey: ['inputs'],
queryFn: listInputs,
})
const [localValues, setLocalValues] = useState<Record<string, unknown>>({})
const [randomSeeds, setRandomSeeds] = useState<Record<string, boolean>>({})
const [imagePicker, setImagePicker] = useState<string | null>(null) // key of slot being picked
const [count, setCount] = useState(1)
// Sync local values from state when stateData arrives
useEffect(() => {
if (stateData) setLocalValues(stateData as Record<string, unknown>)
}, [stateData])
// Update seed field when WS reports completed seed
useEffect(() => {
if (lastSeed != null) {
setLocalValues(v => ({ ...v, seed: lastSeed }))
}
}, [lastSeed])
const putStateMut = useMutation({
mutationFn: (overrides: Record<string, unknown>) => putState(overrides),
onSuccess: () => qc.invalidateQueries({ queryKey: ['state'] }),
})
const deleteKeyMut = useMutation({
mutationFn: (key: string) => deleteStateKey(key),
onSuccess: () => qc.invalidateQueries({ queryKey: ['state'] }),
})
const setValue = (key: string, value: unknown) => {
setLocalValues(v => ({ ...v, [key]: value }))
putStateMut.mutate({ [key]: value })
}
const handleActivateImage = async (imageId: number, slotKey: string) => {
await activateInput(imageId, slotKey)
qc.invalidateQueries({ queryKey: ['inputs'] })
qc.invalidateQueries({ queryKey: ['state'] })
setImagePicker(null)
}
const handleGenerate = () => {
const overrides: Record<string, unknown> = {}
const allInputs = [...(inputsData?.common ?? []), ...(inputsData?.advanced ?? [])]
for (const inp of allInputs) {
if (inp.input_type === 'seed') {
overrides[inp.key] = randomSeeds[inp.key] !== false ? -1 : (localValues[inp.key] ?? -1)
} else if (inp.input_type === 'image') {
// image slot — server reads from state_manager
} else {
const v = localValues[inp.key]
if (v !== undefined && v !== '') overrides[inp.key] = v
}
}
onGenerate(overrides, count)
}
if (inputsLoading) return <div className="text-sm text-gray-400">Loading workflow inputs</div>
if (!inputsData) return <div className="text-sm text-gray-400">No workflow loaded.</div>
const renderField = (inp: NodeInput) => {
const val = localValues[inp.key] ?? inp.current_value
if (inp.input_type === 'text') {
return (
<textarea
rows={3}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-1 focus:ring-blue-500"
value={String(val ?? '')}
onChange={e => setValue(inp.key, e.target.value)}
/>
)
}
if (inp.input_type === 'seed') {
const isRandom = randomSeeds[inp.key] !== false
return (
<div className="flex gap-2 items-center">
<input
type="number"
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-40"
value={isRandom ? '' : String(val ?? '')}
placeholder={isRandom ? 'Random' : undefined}
disabled={isRandom}
onChange={e => setValue(inp.key, Number(e.target.value))}
/>
<button
type="button"
onClick={() => setRandomSeeds(r => ({ ...r, [inp.key]: !isRandom }))}
className={`text-xs px-2 py-1 rounded border ${isRandom ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-400 text-gray-600 dark:text-gray-300'}`}
>
🎲 Random
</button>
</div>
)
}
if (inp.input_type === 'image') {
const activeFilename = String(val ?? '')
const namespacedKey = userLabel ? `${userLabel}_${inp.key}` : inp.key
const activeImg = inputImages?.find(i => i.active_slot_key === namespacedKey)
return (
<div className="flex items-center gap-2">
{activeImg ? (
<img
src={getInputThumb(activeImg.id)}
alt={activeImg.filename}
className="w-16 h-16 object-cover rounded border border-gray-300 dark:border-gray-600"
/>
) : (
<div className="w-16 h-16 rounded border border-dashed border-gray-400 flex items-center justify-center text-xs text-gray-400">none</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[12rem]">{activeFilename || 'No image active'}</span>
<button
type="button"
onClick={() => setImagePicker(imagePicker === inp.key ? null : inp.key)}
className="text-xs bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded px-2 py-0.5"
>
Browse
</button>
</div>
</div>
)
}
if (inp.input_type === 'checkpoint') {
return (
<select
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={String(val ?? '')}
onChange={e => setValue(inp.key, e.target.value)}
>
<option value=""> select checkpoint </option>
{(checkpoints?.models ?? []).map(m => <option key={m} value={m}>{m}</option>)}
</select>
)
}
if (inp.input_type === 'lora') {
return (
<select
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={String(val ?? '')}
onChange={e => setValue(inp.key, e.target.value)}
>
<option value=""> select lora </option>
{(loras?.models ?? []).map(m => <option key={m} value={m}>{m}</option>)}
</select>
)
}
// integer, float, string
return (
<input
type={inp.input_type === 'integer' || inp.input_type === 'float' ? 'number' : 'text'}
step={inp.input_type === 'float' ? 'any' : undefined}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={String(val ?? '')}
placeholder={String(inp.current_value ?? '')}
onChange={e => {
const raw = e.target.value
const coerced = inp.input_type === 'integer' ? parseInt(raw) || raw
: inp.input_type === 'float' ? parseFloat(raw) || raw
: raw
setValue(inp.key, coerced)
}}
/>
)
}
return (
<div className="space-y-4">
{/* Common inputs */}
{inputsData.common.map(inp => (
<div key={inp.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{inp.label}</label>
{renderField(inp)}
{imagePicker === inp.key && (
<ImagePickerGrid
images={inputImages ?? []}
slotKey={inp.key}
onPick={handleActivateImage}
onClose={() => setImagePicker(null)}
/>
)}
</div>
))}
{/* Advanced inputs */}
{inputsData.advanced.length > 0 && (
<details className="border border-gray-200 dark:border-gray-700 rounded">
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none text-gray-700 dark:text-gray-300">
Advanced ({inputsData.advanced.length} inputs)
</summary>
<div className="px-3 pb-3 space-y-3 mt-2">
{inputsData.advanced.map(inp => (
<div key={inp.key}>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{inp.label}</label>
{renderField(inp)}
{imagePicker === inp.key && (
<ImagePickerGrid
images={inputImages ?? []}
slotKey={inp.key}
onPick={handleActivateImage}
onClose={() => setImagePicker(null)}
/>
)}
</div>
))}
</div>
</details>
)}
<div className="flex gap-2 items-center">
<input
type="number"
min={1}
max={20}
value={count}
onChange={e => setCount(Math.max(1, Math.min(20, Number(e.target.value))))}
className="w-16 border border-gray-300 dark:border-gray-600 rounded px-2 py-2 text-sm text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
title="Number of generations to queue"
/>
<button
type="button"
onClick={handleGenerate}
disabled={generating}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-semibold transition-colors"
>
{generating ? '⏳ Generating…' : count > 1 ? `Generate ×${count}` : 'Generate'}
</button>
</div>
</div>
)
}
function ImagePickerGrid({
images,
slotKey,
onPick,
onClose,
}: {
images: { id: number; filename: string }[]
slotKey: string
onPick: (id: number, key: string) => void
onClose: () => void
}) {
return (
<div className="mt-2 border border-gray-300 dark:border-gray-600 rounded p-2 bg-gray-50 dark:bg-gray-800">
<div className="flex justify-between items-center mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Select image for slot: {slotKey}</span>
<button onClick={onClose} className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"></button>
</div>
{images.length === 0 ? (
<p className="text-xs text-gray-400">No images available. Upload some first.</p>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1 max-h-48 overflow-y-auto">
{images.map(img => (
<button
key={img.id}
type="button"
onClick={() => onPick(img.id, slotKey)}
className="relative aspect-square overflow-hidden rounded border border-gray-200 dark:border-gray-600 hover:border-blue-500"
title={img.filename}
>
<LazyImage
thumbSrc={getInputThumb(img.id)}
midSrc={getInputMid(img.id)}
fullSrc={getInputImage(img.id)}
alt={img.filename}
className="w-full h-full"
/>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../hooks/useAuth'
import { useStatus } from '../hooks/useStatus'
import { useGeneration } from '../context/GenerationContext'
const navItems = [
{ to: '/generate', label: 'Generate' },
{ to: '/inputs', label: 'Input Images' },
{ to: '/workflow', label: 'Workflow' },
{ to: '/presets', label: 'Presets' },
{ to: '/status', label: 'Status' },
{ to: '/server', label: 'Server' },
{ to: '/history', label: 'History' },
]
export default function Layout() {
const { user, logout, isAdmin } = useAuth()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [dark, setDark] = useState(() => {
if (typeof window === 'undefined') return false
const stored = localStorage.getItem('dark-mode')
if (stored !== null) return stored === 'true'
return window.matchMedia('(prefers-color-scheme: dark)').matches
})
// Apply dark class on mount and changes
useEffect(() => {
document.documentElement.classList.toggle('dark', dark)
localStorage.setItem('dark-mode', String(dark))
}, [dark])
// Auto-close sidebar on navigation
useEffect(() => {
setSidebarOpen(false)
}, [location.pathname])
const { pendingCount, decrementPending } = useGeneration()
const queryClient = useQueryClient()
const titleResetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const onNodeExecuting = useCallback(() => {
document.title = '⏳ Generating… | ComfyUI Bot'
}, [])
const onGenerationComplete = useCallback(() => {
decrementPending()
queryClient.invalidateQueries({ queryKey: ['history'] })
if (titleResetTimer.current) clearTimeout(titleResetTimer.current)
document.title = 'Done | ComfyUI Bot'
titleResetTimer.current = setTimeout(() => { document.title = 'ComfyUI Bot' }, 5000)
}, [decrementPending, queryClient])
const onGenerationError = useCallback(() => {
decrementPending()
if (titleResetTimer.current) clearTimeout(titleResetTimer.current)
document.title = 'Error | ComfyUI Bot'
titleResetTimer.current = setTimeout(() => { document.title = 'ComfyUI Bot' }, 5000)
}, [decrementPending])
useEffect(() => () => { document.title = 'ComfyUI Bot' }, [])
const { status } = useStatus({ enabled: !!user, onGenerationComplete, onGenerationError, onNodeExecuting })
const comfyReachable = status.comfy?.reachable ?? null
const toggleDark = () => setDark(d => !d)
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/40 z-30 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed md:static inset-y-0 left-0 z-40 w-48 flex-none bg-gray-800 text-gray-100 flex flex-col transition-transform duration-200 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
}`}
>
<div className="p-4 font-bold text-lg border-b border-gray-700 flex items-center gap-2">
<span>ComfyUI Bot</span>
<span
title={comfyReachable == null ? 'Connecting…' : comfyReachable ? 'ComfyUI reachable' : 'ComfyUI unreachable'}
className={`ml-auto w-2 h-2 rounded-full flex-none ${comfyReachable == null ? 'bg-gray-500' : comfyReachable ? 'bg-green-400' : 'bg-red-400'}`}
/>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{navItems.map(({ to, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
}
>
<span className="flex-1">{label}</span>
{to === '/generate' && pendingCount > 0 && (
<span className="ml-2 text-xs bg-yellow-400 text-gray-900 rounded-full px-1.5 py-0.5 leading-none">
{pendingCount}
</span>
)}
</NavLink>
))}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) =>
`block px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
}
>
Admin
</NavLink>
)}
</nav>
<div className="p-3 border-t border-gray-700 text-xs flex items-center gap-2">
<span className="flex-1 truncate">{user?.label ?? '...'}</span>
<button onClick={toggleDark} className="hover:text-yellow-300" title="Toggle dark mode">
{dark ? '☀' : '🌙'}
</button>
<button onClick={logout} className="hover:text-red-400" title="Logout">
</button>
</div>
</aside>
{/* Main */}
<main className="flex-1 overflow-y-auto p-3 sm:p-6 bg-gray-50 dark:bg-gray-900">
{/* Hamburger button — mobile only */}
<button
className="md:hidden mb-3 p-1.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => setSidebarOpen(true)}
aria-label="Open menu"
>
</button>
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import React, { useState } from 'react'
interface Props {
thumbSrc: string
midSrc: string
fullSrc: string
alt: string
className?: string
onClick?: () => void
}
/**
* 3-stage progressive image loader:
* stage 0 — blurred tiny thumb (loads instantly)
* stage 1 — clear medium-compressed image (fades in when ready)
* stage 2 — full original image (fades in when ready)
*
* All three requests fire in parallel; stage only advances forward so if full
* arrives before mid we jump straight to stage 2.
*/
export default function LazyImage({ thumbSrc, midSrc, fullSrc, alt, className = '', onClick }: Props) {
const [stage, setStage] = useState(0)
const advance = (to: number) => setStage(s => Math.max(s, to))
return (
<div className={`relative overflow-hidden ${className}`}>
{/* Stage 0: blurred thumbnail */}
<img
src={thumbSrc}
alt=""
aria-hidden="true"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
stage === 0 ? 'opacity-100 blur-sm scale-105' : 'opacity-0'
}`}
/>
{/* Stage 1: clear mid-resolution */}
<img
src={midSrc}
alt=""
aria-hidden="true"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
stage === 1 ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => advance(1)}
/>
{/* Stage 2: full resolution */}
<img
src={fullSrc}
alt={alt}
loading="lazy"
className={`relative w-full h-full object-cover transition-opacity duration-300 ${
stage === 2 ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => advance(2)}
onClick={onClick}
/>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import React, { createContext, useContext, useState, useCallback } from 'react'
interface Value {
pendingCount: number
addPending: (n?: number) => void
decrementPending: () => void
}
const Ctx = createContext<Value>({
pendingCount: 0,
addPending: () => {},
decrementPending: () => {},
})
export function GenerationProvider({ children }: { children: React.ReactNode }) {
const [pendingCount, setPendingCount] = useState(0)
const addPending = useCallback((n = 1) => setPendingCount(c => c + n), [])
const decrementPending = useCallback(() => setPendingCount(c => Math.max(0, c - 1)), [])
return <Ctx.Provider value={{ pendingCount, addPending, decrementPending }}>{children}</Ctx.Provider>
}
export const useGeneration = () => useContext(Ctx)

View File

@@ -0,0 +1,26 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { authMe, authLogout } from '../api/client'
export function useAuth() {
const qc = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ['auth', 'me'],
queryFn: authMe,
retry: false,
staleTime: 60_000,
})
const logout = async () => {
await authLogout()
qc.clear()
window.location.href = '/login'
}
return {
user: data ?? null,
isLoading,
isAuthenticated: !!data,
isAdmin: data?.admin ?? false,
logout,
}
}

View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react'
import { useWebSocket } from './useWebSocket'
export interface StatusSnapshot {
bot?: { latency_ms: number; uptime: string }
comfy?: {
server: string
reachable?: boolean
queue_pending: number
queue_running: number
workflow_loaded: boolean
last_seed: number | null
total_generated: number
}
overrides?: Record<string, unknown>
service?: { state: string }
upload?: { configured: boolean; running: boolean; total_ok: number; total_fail: number }
}
export interface GenerationResult {
prompt_id: string
seed: number | null
image_count: number
video_count: number
}
interface UseStatusOptions {
enabled?: boolean
onGenerationComplete?: (result: GenerationResult) => void
onGenerationError?: (error: { prompt_id: string | null; error: string }) => void
onNodeExecuting?: (node: string, promptId: string) => void
}
export function useStatus({
enabled = true,
onGenerationComplete,
onGenerationError,
onNodeExecuting,
}: UseStatusOptions) {
const [status, setStatus] = useState<StatusSnapshot>({})
const [executingNode, setExecutingNode] = useState<string | null>(null)
const handleMessage = useCallback(
(msg: { type: string; data: unknown; ts: number }) => {
if (msg.type === 'status_snapshot') {
setStatus(msg.data as StatusSnapshot)
} else if (msg.type === 'node_executing') {
const d = msg.data as { node: string; prompt_id: string }
setExecutingNode(d.node)
onNodeExecuting?.(d.node, d.prompt_id)
} else if (msg.type === 'generation_complete') {
setExecutingNode(null)
onGenerationComplete?.(msg.data as GenerationResult)
} else if (msg.type === 'generation_error') {
setExecutingNode(null)
onGenerationError?.(msg.data as { prompt_id: string | null; error: string })
} else if (msg.type === 'server_state') {
const d = msg.data as { state: string; http_reachable: boolean }
setStatus(prev => ({
...prev,
service: { state: d.state },
}))
}
},
[onGenerationComplete, onGenerationError, onNodeExecuting],
)
useWebSocket({ onMessage: handleMessage, enabled })
return { status, executingNode }
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef, useCallback } from 'react'
interface WSOptions {
onMessage: (data: { type: string; data: unknown; ts: number }) => void
enabled?: boolean
}
/** Reconnecting WebSocket hook with exponential backoff. Auth via ttb_session cookie. */
export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
const wsRef = useRef<WebSocket | null>(null)
const backoffRef = useRef(1000)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onMessageRef = useRef(onMessage)
onMessageRef.current = onMessage
const connect = useCallback(() => {
if (!enabled) return
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${proto}://${window.location.host}/ws`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onmessage = (ev) => {
try {
const parsed = JSON.parse(ev.data)
if (parsed.type !== 'ping') {
onMessageRef.current(parsed)
}
} catch {}
}
ws.onopen = () => {
backoffRef.current = 1000
}
ws.onclose = () => {
if (enabled) {
timerRef.current = setTimeout(() => {
backoffRef.current = Math.min(backoffRef.current * 2, 30_000)
connect()
}, backoffRef.current)
}
}
ws.onerror = () => ws.close()
}, [enabled])
useEffect(() => {
connect()
return () => {
enabled && (wsRef.current?.close())
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [connect, enabled])
}

12
frontend/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light dark;
}
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
font-family: system-ui, -apple-system, sans-serif;
}

19
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 5000 },
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,113 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { adminListTokens, adminCreateToken, adminRevokeToken } from '../api/client'
export default function AdminPage() {
const qc = useQueryClient()
const [label, setLabel] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const [createError, setCreateError] = useState('')
const { data: tokens = [], isLoading } = useQuery({
queryKey: ['admin', 'tokens'],
queryFn: adminListTokens,
})
const createMut = useMutation({
mutationFn: ({ label, admin }: { label: string; admin: boolean }) => adminCreateToken(label, admin),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['admin', 'tokens'] })
setNewToken(res.token)
setLabel('')
setIsAdmin(false)
setCreateError('')
},
onError: (err) => setCreateError(err instanceof Error ? err.message : String(err)),
})
const revokeMut = useMutation({
mutationFn: (id: string) => adminRevokeToken(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'tokens'] }),
})
return (
<div className="max-w-xl mx-auto space-y-6">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Admin Token Management</h1>
{/* Create token */}
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 space-y-3">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Create invite token</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Label (e.g. alice)"
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={isAdmin}
onChange={e => setIsAdmin(e.target.checked)}
className="rounded"
/>
Admin
</label>
<button
onClick={() => createMut.mutate({ label, admin: isAdmin })}
disabled={!label.trim() || createMut.isPending}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-3 py-2 text-sm font-medium"
>
Create
</button>
</div>
{createError && <p className="text-red-500 text-sm">{createError}</p>}
</div>
{/* New token display — one-time */}
{newToken && (
<div className="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded p-4 space-y-2">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
New token (copy now shown only once):
</p>
<code className="block text-xs break-all bg-yellow-100 dark:bg-yellow-900/50 rounded p-2 text-yellow-900 dark:text-yellow-100 select-all">
{newToken}
</code>
<button
onClick={() => setNewToken(null)}
className="text-xs text-yellow-600 dark:text-yellow-400 hover:underline"
>
Dismiss
</button>
</div>
)}
{/* Token list */}
{isLoading ? (
<div className="text-sm text-gray-400">Loading tokens</div>
) : tokens.length === 0 ? (
<p className="text-sm text-gray-400">No tokens yet.</p>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
{tokens.map(t => (
<div key={t.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div>
<span className="font-medium text-gray-700 dark:text-gray-300">{t.label}</span>
{t.admin && <span className="ml-1 text-xs text-purple-600 dark:text-purple-400">(admin)</span>}
<span className="ml-2 text-xs text-gray-400">{new Date(t.created_at).toLocaleDateString()}</span>
</div>
<button
onClick={() => revokeMut.mutate(t.id)}
className="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,250 @@
import React, { useState, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { generate, workflowGen, listPresets, loadPreset } from '../api/client'
import { useStatus, GenerationResult } from '../hooks/useStatus'
import { useAuth } from '../hooks/useAuth'
import { useGeneration } from '../context/GenerationContext'
import DynamicWorkflowForm from '../components/DynamicWorkflowForm'
interface Notification {
id: number
type: 'success' | 'error' | 'info'
msg: string
}
export default function GeneratePage() {
const { user } = useAuth()
const { pendingCount, addPending } = useGeneration()
const qc = useQueryClient()
const [generating, setGenerating] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>([])
const [executingNodeDisplay, setExecutingNodeDisplay] = useState<string | null>(null)
const [lastSeed, setLastSeed] = useState<number | null>(null)
const [mode, setMode] = useState<'workflow' | 'prompt'>('workflow')
const [prompt, setPrompt] = useState('')
const [negPrompt, setNegPrompt] = useState('')
const [promptCount, setPromptCount] = useState(1)
const [loadingPreset, setLoadingPreset] = useState(false)
const { data: presetsData } = useQuery({ queryKey: ['presets'], queryFn: listPresets })
const addNotif = useCallback((type: Notification['type'], msg: string, ttl = 8000) => {
const id = Date.now()
setNotifications(n => [...n, { id, type, msg }])
setTimeout(() => setNotifications(n => n.filter(x => x.id !== id)), ttl)
}, [])
const handlePresetLoad = useCallback(async (name: string) => {
if (!name) return
setLoadingPreset(true)
try {
await loadPreset(name)
qc.invalidateQueries({ queryKey: ['state'] })
qc.invalidateQueries({ queryKey: ['workflowInputs'] })
addNotif('info', `Loaded preset: ${name}`, 3000)
} catch (err: unknown) {
addNotif('error', `Failed to load preset: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setLoadingPreset(false)
}
}, [qc, addNotif])
const dismissNotif = useCallback((id: number) => {
setNotifications(n => n.filter(x => x.id !== id))
}, [])
const onGenerationComplete = useCallback((r: GenerationResult) => {
setExecutingNodeDisplay(null)
setLastSeed(r.seed)
addNotif('success', `Done — seed: ${r.seed ?? 'unknown'} · ${r.image_count} image(s) · ${r.video_count} video(s)`)
}, [addNotif])
const onGenerationError = useCallback((e: { prompt_id: string | null; error: string }) => {
setExecutingNodeDisplay(null)
addNotif('error', e.error)
}, [addNotif])
const onNodeExecuting = useCallback((node: string) => {
setExecutingNodeDisplay(node)
}, [])
const { status } = useStatus({
enabled: !!user,
onGenerationComplete,
onGenerationError,
onNodeExecuting,
})
const handleWorkflowGenerate = async (overrides: Record<string, unknown>, count: number = 1) => {
setGenerating(true)
document.title = '⏳ Queued… | ComfyUI Bot'
try {
const res = await workflowGen({ count, overrides })
setGenerating(false)
addPending(res.count ?? count)
addNotif('info', `Queued ${res.count ?? count} generation(s) at position ${res.queue_position}`, 3000)
} catch (err: unknown) {
setGenerating(false)
addNotif('error', err instanceof Error ? err.message : String(err))
document.title = 'ComfyUI Bot'
}
}
const handlePromptGenerate = async () => {
const n = Math.max(1, Math.min(20, promptCount))
setGenerating(true)
document.title = '⏳ Queued… | ComfyUI Bot'
try {
let lastPos = 0
for (let i = 0; i < n; i++) {
const res = await generate({ prompt, negative_prompt: negPrompt })
lastPos = res.queue_position
}
setGenerating(false)
addPending(n)
addNotif('info', `Queued ${n} generation(s) at position ${lastPos}`, 3000)
} catch (err: unknown) {
setGenerating(false)
addNotif('error', err instanceof Error ? err.message : String(err))
document.title = 'ComfyUI Bot'
}
}
const queuePending = (status.comfy?.queue_pending ?? 0)
const queueRunning = (status.comfy?.queue_running ?? 0)
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Generate</h1>
<div className="text-xs text-gray-400">
ComfyUI: {queueRunning} running, {queuePending} pending
</div>
</div>
{/* Mode toggle */}
<div className="flex flex-wrap gap-2 text-sm">
<button
onClick={() => setMode('workflow')}
className={`px-3 py-1.5 rounded ${mode === 'workflow' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
>
Workflow mode
</button>
<button
onClick={() => setMode('prompt')}
className={`px-3 py-1.5 rounded ${mode === 'prompt' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
>
Prompt mode
</button>
</div>
{/* Quick-load preset */}
{(presetsData?.presets ?? []).length > 0 && (
<div className="flex items-center gap-2">
<select
defaultValue=""
disabled={loadingPreset}
onChange={e => {
const v = e.target.value
e.target.value = ''
if (v) handlePresetLoad(v)
}}
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value="" disabled>Load a preset</option>
{(presetsData?.presets ?? []).map(p => (
<option key={p.name} value={p.name}>
{p.name}{p.description ? `${p.description}` : ''}
</option>
))}
</select>
{loadingPreset && <span className="text-xs text-gray-400">Loading</span>}
</div>
)}
{/* Progress banner */}
{(pendingCount > 0 || executingNodeDisplay) && (
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded p-3 text-sm text-blue-700 dark:text-blue-300">
{pendingCount > 0 && `${pendingCount} generation(s) in progress`}
{executingNodeDisplay && ` · running: ${executingNodeDisplay}`}
</div>
)}
{/* Form */}
{mode === 'workflow' ? (
<DynamicWorkflowForm
onGenerate={handleWorkflowGenerate}
lastSeed={lastSeed}
generating={generating}
userLabel={user?.label}
/>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prompt</label>
<textarea
rows={3}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="Describe what you want to generate"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Negative prompt</label>
<textarea
rows={2}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
value={negPrompt}
onChange={e => setNegPrompt(e.target.value)}
placeholder="What to avoid"
/>
</div>
<div className="flex gap-2 items-center">
<input
type="number"
min={1}
max={20}
value={promptCount}
onChange={e => setPromptCount(Math.max(1, Math.min(20, Number(e.target.value))))}
className="w-16 border border-gray-300 dark:border-gray-600 rounded px-2 py-2 text-sm text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
title="Number of generations to queue"
/>
<button
onClick={handlePromptGenerate}
disabled={generating || !prompt.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-semibold transition-colors"
>
{generating ? '⏳ Queuing…' : promptCount > 1 ? `Generate ×${promptCount}` : 'Generate'}
</button>
</div>
</div>
)}
{/* Toast notification stack */}
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{notifications.map(n => (
<div
key={n.id}
className={`flex items-start gap-2 rounded border p-3 text-sm shadow-md ${
n.type === 'success'
? 'bg-green-50 dark:bg-green-900/40 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300'
: n.type === 'error'
? 'bg-red-50 dark:bg-red-900/40 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300'
: 'bg-blue-50 dark:bg-blue-900/40 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'
}`}
>
<span className="flex-1">{n.msg}</span>
<button
onClick={() => dismissNotif(n.id)}
className="flex-none opacity-60 hover:opacity-100 leading-none"
aria-label="Dismiss"
>
</button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,382 @@
import React, { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getHistory, createHistoryShare, revokeHistoryShare, savePresetFromHistory } from '../api/client'
import { useAuth } from '../hooks/useAuth'
interface HistoryRow {
id: number
prompt_id: string
source: string
user_label?: string
overrides: Record<string, unknown>
seed?: number
file_paths?: string[]
created_at: string
share_token?: string | null
}
/** Debounce a value by `delay` ms. */
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(t)
}, [value, delay])
return debounced
}
export default function HistoryPage() {
const [lightbox, setLightbox] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState('')
const { user } = useAuth()
const debouncedQ = useDebounce(searchInput.trim(), 300)
const { data, isLoading } = useQuery({
queryKey: ['history', debouncedQ],
queryFn: () => getHistory(debouncedQ || undefined),
refetchInterval: 10_000,
})
const rows: HistoryRow[] = ((data?.history ?? []) as unknown as HistoryRow[])
const isSearching = searchInput.trim() !== debouncedQ
if (isLoading && !searchInput) return <div className="text-sm text-gray-400">Loading history</div>
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">History</h1>
{/* Search */}
<div className="relative">
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="Search by prompt, checkpoint, seed…"
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs px-1"
>
</button>
)}
</div>
{isSearching && <p className="text-xs text-gray-400">Searching</p>}
{isLoading ? (
<div className="text-sm text-gray-400">Loading</div>
) : rows.length === 0 ? (
<p className="text-sm text-gray-400">
{debouncedQ ? `No results for "${debouncedQ}".` : 'No generation history yet.'}
</p>
) : (
<>
{/* Desktop table — hidden on mobile */}
<div className="hidden sm:block overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th className="pb-2 pr-4">Time</th>
<th className="pb-2 pr-4">Source</th>
<th className="pb-2 pr-4">User</th>
<th className="pb-2 pr-4">Seed</th>
<th className="pb-2">Files</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<React.Fragment key={row.prompt_id}>
<tr
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
onClick={() => setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)}
>
<td className="py-2 pr-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{new Date(row.created_at).toLocaleString()}
</td>
<td className="py-2 pr-4">
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
{row.source}
</span>
</td>
<td className="py-2 pr-4 text-gray-600 dark:text-gray-400">{row.user_label ?? '—'}</td>
<td className="py-2 pr-4 font-mono text-gray-700 dark:text-gray-300">{row.seed ?? '—'}</td>
<td className="py-2 text-gray-500 dark:text-gray-400">{(row.file_paths ?? []).length} file(s)</td>
</tr>
{expandedId === row.prompt_id && (
<tr className="bg-gray-50 dark:bg-gray-800/50">
<td colSpan={5} className="px-3 py-3">
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* Mobile card list — hidden on sm+ */}
<div className="sm:hidden space-y-2">
{rows.map(row => {
const isExpanded = expandedId === row.prompt_id
const dt = new Date(row.created_at)
const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const dateStr = dt.toLocaleDateString([], { month: 'short', day: 'numeric' })
return (
<div key={row.prompt_id} className="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800">
<button
className="w-full text-left px-3 py-2 space-y-1"
onClick={() => setExpandedId(isExpanded ? null : row.prompt_id)}
>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 dark:text-gray-400">{timeStr} · {dateStr}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
{row.source}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span>User: {row.user_label ?? '—'}</span>
<span>Seed: {row.seed ?? '—'}</span>
<span>{(row.file_paths ?? []).length} file(s)</span>
<span className="ml-auto text-gray-400">{isExpanded ? '▲' : '▼'}</span>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 border-t border-gray-100 dark:border-gray-700 pt-2">
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
</div>
)}
</div>
)
})}
</div>
</>
)}
{/* Lightbox */}
{lightbox && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={() => setLightbox(null)}
>
<button
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
onClick={() => setLightbox(null)}
aria-label="Close"
>
</button>
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
</div>
)}
</div>
)
}
function ExpandedRow({
row,
onLightbox,
currentUserLabel,
}: {
row: HistoryRow
onLightbox: (url: string) => void
currentUserLabel: string | null
}) {
const qc = useQueryClient()
const [copied, setCopied] = useState(false)
const [showSavePreset, setShowSavePreset] = useState(false)
const [presetName, setPresetName] = useState('')
const [presetDesc, setPresetDesc] = useState('')
const [presetMsg, setPresetMsg] = useState<{ ok: boolean; text: string } | null>(null)
const presetNameRef = useRef<HTMLInputElement>(null)
const { data: imagesData, isLoading } = useQuery({
queryKey: ['history', row.prompt_id, 'images'],
queryFn: async () => {
const res = await fetch(`/api/history/${row.prompt_id}/images`, { credentials: 'include' })
if (!res.ok) throw new Error(`${res.status}`)
return res.json() as Promise<{ images: Array<{ filename: string; data: string | null; mime_type: string }> }>
},
})
const shareMut = useMutation({
mutationFn: () => createHistoryShare(row.prompt_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
})
const revokeMut = useMutation({
mutationFn: () => revokeHistoryShare(row.prompt_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
})
const savePresetMut = useMutation({
mutationFn: () => savePresetFromHistory(row.prompt_id, presetName.trim(), presetDesc.trim() || undefined),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['presets'] })
setPresetMsg({ ok: true, text: `Saved as preset "${res.name}"` })
setPresetName('')
setPresetDesc('')
},
onError: (err) => {
setPresetMsg({ ok: false, text: err instanceof Error ? err.message : String(err) })
},
})
const shareUrl = row.share_token
? `${window.location.origin}/share/${row.share_token}`
: null
const isOwner = currentUserLabel !== null && row.user_label === currentUserLabel
const handleCopy = () => {
if (!shareUrl) return
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="space-y-3">
{/* Overrides */}
<details>
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-32 text-gray-600 dark:text-gray-400">
{JSON.stringify(row.overrides, null, 2)}
</pre>
</details>
{/* Images */}
{isLoading ? (
<p className="text-xs text-gray-400">Loading images</p>
) : (imagesData?.images ?? []).length > 0 ? (
<div className="flex gap-2 flex-wrap">
{(imagesData?.images ?? []).map((img, i) => {
const isVideo = img.mime_type.startsWith('video/')
if (isVideo) {
const videoSrc = `/api/history/${row.prompt_id}/file/${encodeURIComponent(img.filename)}`
return (
<video
key={i}
src={videoSrc}
controls
className="rounded max-h-40"
/>
)
}
const src = `data:${img.mime_type};base64,${img.data}`
return (
<img
key={i}
src={src}
alt={img.filename}
className="rounded max-h-40 cursor-pointer border border-gray-200 dark:border-gray-700"
onClick={() => onLightbox(src)}
/>
)
})}
</div>
) : (
<p className="text-xs text-gray-400">Files not available (may have been moved or deleted).</p>
)}
{/* Owner-only actions */}
{isOwner && (
<div className="pt-1 border-t border-gray-100 dark:border-gray-700 space-y-2">
{/* Share section */}
{shareUrl ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
<div className="flex items-center gap-2 flex-wrap">
<code className="text-xs bg-gray-100 dark:bg-gray-700 rounded px-2 py-1 text-gray-700 dark:text-gray-300 break-all select-all flex-1 min-w-0">
{shareUrl}
</code>
<button
onClick={handleCopy}
className="shrink-0 text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => revokeMut.mutate()}
disabled={revokeMut.isPending}
className="shrink-0 text-xs px-2 py-1 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50 transition-colors"
>
{revokeMut.isPending ? 'Revoking…' : 'Revoke'}
</button>
</div>
</div>
) : (
<button
onClick={() => shareMut.mutate()}
disabled={shareMut.isPending}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
{shareMut.isPending ? 'Creating link…' : 'Share'}
</button>
)}
{/* Save-as-preset section */}
{!showSavePreset ? (
<button
onClick={() => { setShowSavePreset(true); setPresetMsg(null); setTimeout(() => presetNameRef.current?.focus(), 50) }}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Save as preset
</button>
) : (
<div className="space-y-1.5">
<p className="text-xs text-gray-500 dark:text-gray-400">Save overrides as preset</p>
<p className="text-xs text-amber-600 dark:text-amber-400">
Note: workflow template is not saved load it separately before using this preset.
</p>
<div className="flex gap-2 flex-wrap">
<input
ref={presetNameRef}
type="text"
value={presetName}
onChange={e => setPresetName(e.target.value)}
placeholder="Preset name"
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
value={presetDesc}
onChange={e => setPresetDesc(e.target.value)}
placeholder="Description (optional)"
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => savePresetMut.mutate()}
disabled={!presetName.trim() || savePresetMut.isPending}
className="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white transition-colors"
>
{savePresetMut.isPending ? 'Saving…' : 'Save'}
</button>
<button
onClick={() => { setShowSavePreset(false); setPresetMsg(null); setPresetName(''); setPresetDesc('') }}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
{presetMsg && (
<p className={`text-xs ${presetMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-red-500'}`}>
{presetMsg.text}
</p>
)}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,216 @@
import React, { useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
listInputs,
uploadInput,
activateInput,
deleteInput,
getInputImage,
getInputThumb,
getInputMid,
getWorkflowInputs,
getState,
InputImage,
} from '../api/client'
import LazyImage from '../components/LazyImage'
export default function InputImagesPage() {
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadSlot, setUploadSlot] = useState<string | null>(null)
const [lightbox, setLightbox] = useState<string | null>(null)
const { data: images = [], isLoading } = useQuery({ queryKey: ['inputs'], queryFn: listInputs })
const { data: inputsData } = useQuery({ queryKey: ['workflow', 'inputs'], queryFn: getWorkflowInputs })
const { data: stateData } = useQuery({ queryKey: ['state'], queryFn: getState })
const imageSlots = inputsData
? [...inputsData.common, ...inputsData.advanced].filter(i => i.input_type === 'image')
: []
const deleteMut = useMutation({
mutationFn: (id: number) => deleteInput(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['inputs'] }),
})
const activateMut = useMutation({
mutationFn: ({ id, slotKey }: { id: number; slotKey: string }) => activateInput(id, slotKey),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inputs'] })
qc.invalidateQueries({ queryKey: ['state'] })
},
})
const handleUpload = async (slotKey: string) => {
if (!fileRef.current?.files?.length) return
setUploading(true)
setUploadSlot(slotKey)
try {
await uploadInput(fileRef.current.files[0], slotKey)
qc.invalidateQueries({ queryKey: ['inputs'] })
qc.invalidateQueries({ queryKey: ['state'] })
if (fileRef.current) fileRef.current.value = ''
} finally {
setUploading(false)
setUploadSlot(null)
}
}
const activeForSlot = (slotKey: string): string => {
const st = stateData as Record<string, unknown> | undefined
return String(st?.[slotKey] ?? '')
}
if (isLoading) return <div className="text-sm text-gray-400">Loading images</div>
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Input Images</h1>
{/* Per-slot sections */}
{imageSlots.length > 0 ? (
imageSlots.map(slot => {
const activeFilename = activeForSlot(slot.key)
return (
<section key={slot.key} className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{slot.label}
{activeFilename && (
<span className="ml-2 text-xs font-normal text-blue-500">(active: {activeFilename})</span>
)}
</h2>
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
{uploading && uploadSlot === slot.key ? 'Uploading…' : 'Upload'}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={() => handleUpload(slot.key)}
/>
</label>
</div>
<ImageGrid
images={images}
activeFilename={activeFilename}
slotKey={slot.key}
onActivate={(id) => activateMut.mutate({ id, slotKey: slot.key })}
onDelete={(id) => deleteMut.mutate(id)}
onLightbox={setLightbox}
/>
</section>
)
})
) : (
/* No workflow loaded — show flat list */
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">All images</h2>
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
{uploading ? 'Uploading…' : 'Upload'}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={() => handleUpload('input_image')}
/>
</label>
</div>
<ImageGrid
images={images}
activeFilename=""
slotKey="input_image"
onActivate={(id) => activateMut.mutate({ id, slotKey: 'input_image' })}
onDelete={(id) => deleteMut.mutate(id)}
onLightbox={setLightbox}
/>
</section>
)}
{images.length === 0 && (
<p className="text-sm text-gray-400">No images yet. Upload one to get started.</p>
)}
{/* Lightbox */}
{lightbox && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={() => setLightbox(null)}
>
<button
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
onClick={() => setLightbox(null)}
aria-label="Close"
>
</button>
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
</div>
)}
</div>
)
}
function ImageGrid({
images,
activeFilename,
slotKey,
onActivate,
onDelete,
onLightbox,
}: {
images: InputImage[]
activeFilename: string
slotKey: string
onActivate: (id: number) => void
onDelete: (id: number) => void
onLightbox: (url: string) => void
}) {
if (images.length === 0) return <p className="text-xs text-gray-400">No images.</p>
return (
<div className="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-8 gap-2">
{images.map(img => {
const isActive = img.filename === activeFilename
return (
<div
key={img.id}
className={`relative group aspect-square rounded border-2 overflow-hidden cursor-pointer ${
isActive ? 'border-blue-500' : 'border-transparent'
}`}
>
<LazyImage
thumbSrc={getInputThumb(img.id)}
midSrc={getInputMid(img.id)}
fullSrc={getInputImage(img.id)}
alt={img.filename}
className="w-full h-full"
onClick={() => onLightbox(getInputImage(img.id))}
/>
{isActive && (
<div className="absolute top-0.5 left-0.5 bg-blue-500 text-white text-[9px] px-1 rounded">active</div>
)}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
{!isActive && (
<button
onClick={() => onActivate(img.id)}
className="text-[10px] bg-blue-600 text-white rounded px-1.5 py-0.5 hover:bg-blue-700"
>
Activate
</button>
)}
<button
onClick={() => onDelete(img.id)}
className="text-[10px] bg-red-600 text-white rounded px-1.5 py-0.5 hover:bg-red-700"
>
Delete
</button>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { authLogin, adminLogin } from '../api/client'
import { useQueryClient } from '@tanstack/react-query'
export default function LoginPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [token, setToken] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [isAdmin, setIsAdmin] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (isAdmin) {
await adminLogin(token)
} else {
await authLogin(token)
}
await qc.invalidateQueries({ queryKey: ['auth', 'me'] })
navigate('/generate')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('429')) {
setError('Too many failed attempts. Please wait 1 hour before trying again.')
} else if (msg.includes('401')) {
setError('Invalid token or password.')
} else {
setError(msg)
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm">
<h1 className="text-xl font-bold mb-6 text-gray-800 dark:text-gray-100">ComfyUI Bot</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{isAdmin ? 'Admin password' : 'Invite token'}
</label>
<input
type="password"
value={token}
onChange={e => setToken(e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={isAdmin ? 'Password' : 'Paste your invite token'}
required
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-medium transition-colors"
>
{loading ? 'Logging in…' : 'Log in'}
</button>
</form>
<button
onClick={() => { setIsAdmin(a => !a); setError('') }}
className="mt-4 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 w-full text-center"
>
{isAdmin ? 'Use invite token instead' : 'Admin login'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { listPresets, savePreset, getPreset, loadPreset, deletePreset, PresetMeta } from '../api/client'
export default function PresetsPage() {
const qc = useQueryClient()
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [savingError, setSavingError] = useState('')
const [expanded, setExpanded] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['presets'], queryFn: listPresets })
const { data: presetDetail } = useQuery({
queryKey: ['preset', expanded],
queryFn: () => getPreset(expanded!),
enabled: !!expanded,
})
const saveMut = useMutation({
mutationFn: ({ name, description }: { name: string; description: string }) =>
savePreset(name, description || undefined),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['presets'] })
setNewName('')
setNewDescription('')
setSavingError('')
setMessage('Preset saved.')
},
onError: (err) => setSavingError(err instanceof Error ? err.message : String(err)),
})
const loadMut = useMutation({
mutationFn: (name: string) => loadPreset(name),
onSuccess: (_, name) => {
qc.invalidateQueries({ queryKey: ['state'] })
qc.invalidateQueries({ queryKey: ['workflowInputs'] })
setMessage(`Loaded preset: ${name}`)
},
})
const deleteMut = useMutation({
mutationFn: (name: string) => deletePreset(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['presets'] })
setMessage('Preset deleted.')
},
})
return (
<div className="max-w-xl mx-auto space-y-6">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Presets</h1>
{message && (
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
)}
{/* Save current state */}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Preset name"
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={() => { setSavingError(''); saveMut.mutate({ name: newName, description: newDescription }) }}
disabled={!newName.trim() || saveMut.isPending}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-3 py-2 text-sm font-medium"
>
Save current state
</button>
</div>
<input
type="text"
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
placeholder="Description (optional)"
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{savingError && <p className="text-red-500 text-sm">{savingError}</p>}
{/* Preset list */}
{isLoading ? (
<div className="text-sm text-gray-400">Loading presets</div>
) : (data?.presets ?? []).length === 0 ? (
<p className="text-sm text-gray-400">No presets saved yet.</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
{(data?.presets ?? []).map((preset: PresetMeta) => (
<li key={preset.name} className="px-3 py-2 space-y-1">
<div className="flex items-center justify-between">
<button
onClick={() => setExpanded(expanded === preset.name ? null : preset.name)}
className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 text-left"
>
{preset.name}
{preset.owner && (
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
{preset.owner}
</span>
)}
</button>
<div className="flex gap-2">
<button
onClick={() => { setMessage(null); loadMut.mutate(preset.name) }}
disabled={loadMut.isPending}
className="text-xs bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white rounded px-2 py-1"
>
Load
</button>
<button
onClick={() => { setMessage(null); deleteMut.mutate(preset.name) }}
className="text-xs bg-red-600 hover:bg-red-700 text-white rounded px-2 py-1"
>
Delete
</button>
</div>
</div>
{preset.description && (
<p className="text-xs italic text-gray-400 dark:text-gray-500">{preset.description}</p>
)}
{expanded === preset.name && presetDetail && (
<PresetDetail data={presetDetail} />
)}
</li>
))}
</ul>
)}
</div>
)
}
function PresetDetail({ data }: { data: Record<string, unknown> }) {
const state = (data.state ?? {}) as Record<string, unknown>
const { prompt, negative_prompt, seed, ...otherOverrides } = state
const hasOther = Object.keys(otherOverrides).length > 0
return (
<div className="mt-2 space-y-2 text-xs">
{prompt != null && (
<div>
<span className="font-semibold text-gray-600 dark:text-gray-400">Prompt</span>
<p className="mt-0.5 bg-gray-50 dark:bg-gray-800 rounded p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{String(prompt)}
</p>
</div>
)}
{negative_prompt != null && (
<div>
<span className="font-semibold text-gray-600 dark:text-gray-400">Negative prompt</span>
<p className="mt-0.5 bg-gray-50 dark:bg-gray-800 rounded p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{String(negative_prompt)}
</p>
</div>
)}
{seed != null && (
<div className="flex gap-2 items-baseline">
<span className="font-semibold text-gray-600 dark:text-gray-400">Seed</span>
<span className="font-mono text-gray-700 dark:text-gray-300">
{String(seed)}
{seed === -1 && <span className="ml-1 text-gray-400">(random)</span>}
</span>
</div>
)}
{hasOther && (
<div>
<span className="font-semibold text-gray-600 dark:text-gray-400">Other overrides</span>
<table className="mt-0.5 w-full text-xs border-collapse">
<tbody>
{Object.entries(otherOverrides).map(([k, v]) => (
<tr key={k} className="border-b border-gray-100 dark:border-gray-700">
<td className="py-0.5 pr-3 font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap">{k}</td>
<td className="py-0.5 text-gray-700 dark:text-gray-300 break-all">{JSON.stringify(v)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!!data.workflow && (
<div className="text-green-600 dark:text-green-400">Includes workflow template</div>
)}
<details>
<summary className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Raw JSON</summary>
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 rounded p-2 overflow-auto max-h-48 text-gray-600 dark:text-gray-400">
{JSON.stringify(data, null, 2)}
</pre>
</details>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useRef, useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { getServerStatus, serverAction, tailLogs } from '../api/client'
const ACTIONS = ['start', 'stop', 'restart'] as const
export default function ServerPage() {
const [actionMsg, setActionMsg] = useState<string | null>(null)
const logRef = useRef<HTMLPreElement>(null)
const { data: srv, refetch: refetchStatus } = useQuery({
queryKey: ['server', 'status'],
queryFn: getServerStatus,
refetchInterval: 5000,
})
const { data: logsData, refetch: refetchLogs } = useQuery({
queryKey: ['logs'],
queryFn: () => tailLogs(200),
refetchInterval: 2000,
})
// Auto-scroll log to bottom
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight
}
}, [logsData])
const actionMut = useMutation({
mutationFn: (action: string) => serverAction(action),
onSuccess: (_, action) => {
setActionMsg(`${action} sent.`)
setTimeout(() => { setActionMsg(null); refetchStatus() }, 2000)
},
onError: (err) => setActionMsg(`Error: ${err instanceof Error ? err.message : String(err)}`),
})
const stateColor = srv?.service_state === 'SERVICE_RUNNING'
? 'text-green-600 dark:text-green-400'
: srv?.service_state === 'SERVICE_STOPPED'
? 'text-red-500 dark:text-red-400'
: 'text-yellow-500'
return (
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Server</h1>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 text-sm space-y-1">
<p>
State: <span className={`font-medium ${stateColor}`}>{srv?.service_state ?? '—'}</span>
</p>
<p>
HTTP: <span className={srv?.http_reachable ? 'text-green-600 dark:text-green-400' : 'text-red-500'}>
{srv == null ? '—' : srv.http_reachable ? '✅ reachable' : '❌ unreachable'}
</span>
</p>
</div>
{/* Controls */}
<div className="flex gap-2">
{ACTIONS.map(a => (
<button
key={a}
onClick={() => { setActionMsg(null); actionMut.mutate(a) }}
disabled={actionMut.isPending}
className={`text-sm rounded px-3 py-2 font-medium disabled:opacity-50 transition-colors ${
a === 'stop' ? 'bg-red-600 hover:bg-red-700 text-white'
: a === 'restart' ? 'bg-yellow-500 hover:bg-yellow-600 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
{actionMsg && <p className="text-sm text-blue-600 dark:text-blue-400">{actionMsg}</p>}
{/* Log tail */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Log tail (last 200 lines)</p>
<button
onClick={() => refetchLogs()}
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
Refresh
</button>
</div>
<pre
ref={logRef}
className="bg-gray-900 text-gray-100 text-xs rounded p-3 h-72 overflow-y-auto whitespace-pre-wrap font-mono"
>
{(logsData?.lines ?? []).join('\n') || 'No log lines available.'}
</pre>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import React from 'react'
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getShareFileUrl } from '../api/client'
interface ShareData {
prompt_id: string
created_at: string
overrides: Record<string, unknown>
seed?: number
images: Array<{ filename: string; data: string | null; mime_type: string }>
}
export default function SharePage() {
const { token } = useParams<{ token: string }>()
const { data, isLoading, error } = useQuery({
queryKey: ['share', token],
queryFn: async () => {
const res = await fetch(`/api/share/${token}`, { credentials: 'include' })
if (!res.ok) {
const msg = await res.text().catch(() => res.statusText)
throw Object.assign(new Error(msg), { status: res.status })
}
return res.json() as Promise<ShareData>
},
retry: false,
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<p className="text-sm text-gray-400">Loading</p>
</div>
)
}
const status = (error as any)?.status
if (status === 401) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm text-center space-y-4">
<p className="text-gray-700 dark:text-gray-300">You need to be logged in to view this shared link.</p>
<Link
to="/login"
className="inline-block bg-blue-600 hover:bg-blue-700 text-white rounded px-4 py-2 text-sm font-medium transition-colors"
>
Log in
</Link>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm text-center">
<p className="text-gray-700 dark:text-gray-300">This share link has been revoked or does not exist.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-4">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Shared Generation</h1>
<div className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p>Generated: {data && new Date(data.created_at).toLocaleString()}</p>
{data?.seed != null && (
<p>Seed: <span className="font-mono">{data.seed}</span></p>
)}
</div>
{data && Object.keys(data.overrides).length > 0 && (
<details>
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-40 text-gray-600 dark:text-gray-400">
{JSON.stringify(data.overrides, null, 2)}
</pre>
</details>
)}
{/* Images / Videos */}
<div className="flex gap-3 flex-wrap">
{(data?.images ?? []).map((img, i) => {
if (img.mime_type.startsWith('video/')) {
return (
<video
key={i}
src={getShareFileUrl(token!, img.filename)}
controls
className="rounded max-h-80 max-w-full"
/>
)
}
return (
<img
key={i}
src={`data:${img.mime_type};base64,${img.data}`}
alt={img.filename}
className="rounded max-h-80 max-w-full object-contain border border-gray-200 dark:border-gray-700"
/>
)
})}
</div>
</div>
<p className="text-center text-xs text-gray-400">
<Link to="/login" className="hover:underline">ComfyUI Bot</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import React from 'react'
import { useAuth } from '../hooks/useAuth'
import { useStatus } from '../hooks/useStatus'
export default function StatusPage() {
const { user } = useAuth()
const { status, executingNode } = useStatus({ enabled: !!user })
const { bot, comfy, service, upload } = status
return (
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Status</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Bot */}
<Card title="Bot">
<Row label="Latency" value={bot ? `${bot.latency_ms} ms` : '—'} />
<Row label="Uptime" value={bot?.uptime ?? '—'} />
</Card>
{/* ComfyUI */}
<Card title="ComfyUI">
<Row label="Server" value={comfy?.server ?? '—'} />
<Row
label="Reachable"
value={comfy?.reachable == null ? '—' : comfy.reachable ? '✅ yes' : '❌ no'}
/>
<Row label="Queue running" value={String(comfy?.queue_running ?? 0)} />
<Row label="Queue pending" value={String(comfy?.queue_pending ?? 0)} />
<Row label="Workflow loaded" value={comfy?.workflow_loaded ? '✓' : '✗'} />
<Row label="Last seed" value={comfy?.last_seed != null ? String(comfy.last_seed) : '—'} />
<Row label="Total generated" value={String(comfy?.total_generated ?? 0)} />
</Card>
{/* Service */}
<Card title="Service">
<Row label="State" value={service?.state ?? '—'} />
</Card>
{/* Auto-upload */}
<Card title="Auto-upload">
<Row label="Configured" value={upload?.configured ? '✓' : '✗'} />
<Row label="Running" value={upload?.running ? '⏳ yes' : 'idle'} />
<Row label="Total ok" value={String(upload?.total_ok ?? 0)} />
<Row label="Total fail" value={String(upload?.total_fail ?? 0)} />
</Card>
</div>
{/* Executing node */}
{executingNode && (
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded p-3 text-sm text-blue-700 dark:text-blue-300">
Executing node: <strong>{executingNode}</strong>
</div>
)}
</div>
)
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">{title}</p>
<dl className="space-y-1">{children}</dl>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-sm">
<dt className="text-gray-500 dark:text-gray-400">{label}</dt>
<dd className="text-gray-800 dark:text-gray-200 font-mono text-right">{value}</dd>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import React, { useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getWorkflow,
getWorkflowInputs,
listWorkflowFiles,
uploadWorkflow,
loadWorkflow,
} from '../api/client'
export default function WorkflowPage() {
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [loadingFile, setLoadingFile] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const { data: wf } = useQuery({ queryKey: ['workflow'], queryFn: getWorkflow })
const { data: inputs } = useQuery({
queryKey: ['workflow', 'inputs'],
queryFn: getWorkflowInputs,
enabled: wf?.loaded ?? false,
})
const { data: filesData, refetch: refetchFiles } = useQuery({
queryKey: ['workflow', 'files'],
queryFn: listWorkflowFiles,
})
const loadMut = useMutation({
mutationFn: (filename: string) => loadWorkflow(filename),
onSuccess: (_, filename) => {
setMessage(`Loaded: ${filename}`)
qc.invalidateQueries({ queryKey: ['workflow'] })
qc.invalidateQueries({ queryKey: ['state'] })
setLoadingFile(null)
},
onError: (err) => {
setMessage(`Error: ${err instanceof Error ? err.message : String(err)}`)
setLoadingFile(null)
},
})
const handleUpload = async () => {
if (!fileRef.current?.files?.length) return
setUploading(true)
setMessage(null)
try {
const res = await uploadWorkflow(fileRef.current.files[0])
setMessage(`Uploaded: ${res.filename ?? 'ok'}`)
refetchFiles()
if (fileRef.current) fileRef.current.value = ''
} catch (err) {
setMessage(`Upload error: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setUploading(false)
}
}
const allInputs = [...(inputs?.common ?? []), ...(inputs?.advanced ?? [])]
return (
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Workflow</h1>
{/* Current workflow */}
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 text-sm space-y-1">
<p className="font-medium text-gray-700 dark:text-gray-300">Current workflow</p>
{wf?.loaded ? (
<>
<p className="text-gray-500 dark:text-gray-400">{wf.last_workflow_file ?? '(loaded from state)'}</p>
<p className="text-gray-500 dark:text-gray-400">{wf.node_count} node(s) detected</p>
</>
) : (
<p className="text-gray-400">No workflow loaded</p>
)}
</div>
{message && (
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
)}
{/* Upload */}
<div className="flex items-center gap-3">
<label className="cursor-pointer text-sm bg-blue-600 hover:bg-blue-700 text-white rounded px-3 py-2">
{uploading ? 'Uploading…' : 'Upload workflow JSON'}
<input
ref={fileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleUpload}
/>
</label>
<span className="text-xs text-gray-400">Uploads to workflows/ folder</span>
</div>
{/* Available files */}
{(filesData?.files ?? []).length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available workflows</p>
<ul className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
{(filesData?.files ?? []).map(f => (
<li key={f} className="flex items-center justify-between px-3 py-2 text-sm">
<span className={`text-gray-700 dark:text-gray-300 ${wf?.last_workflow_file === f ? 'font-semibold text-blue-600 dark:text-blue-400' : ''}`}>
{f}
{wf?.last_workflow_file === f && <span className="ml-1 text-xs">(active)</span>}
</span>
<button
onClick={() => { setLoadingFile(f); setMessage(null); loadMut.mutate(f) }}
disabled={loadingFile === f}
className="text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded px-2 py-1 disabled:opacity-50"
>
{loadingFile === f ? 'Loading…' : 'Load'}
</button>
</li>
))}
</ul>
</div>
)}
{/* Discovered inputs summary */}
{inputs && allInputs.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Discovered inputs ({allInputs.length})</p>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse border border-gray-200 dark:border-gray-700 rounded">
<thead>
<tr className="bg-gray-100 dark:bg-gray-700">
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Key</th>
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Label</th>
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Type</th>
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Common</th>
</tr>
</thead>
<tbody>
{allInputs.map(inp => (
<tr key={inp.key} className="border-b border-gray-100 dark:border-gray-700 last:border-0">
<td className="p-2 font-mono text-gray-600 dark:text-gray-400">{inp.key}</td>
<td className="p-2 text-gray-700 dark:text-gray-300">{inp.label}</td>
<td className="p-2 text-gray-500 dark:text-gray-400">{inp.input_type}</td>
<td className="p-2 text-gray-500 dark:text-gray-400">{inp.is_common ? '✓' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}