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

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>
)
}