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>
197 lines
8.0 KiB
TypeScript
197 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|