From 1d9bc693d25430f34ba801bf99e02db37ff6e208 Mon Sep 17 00:00:00 2001 From: Dawid Wenderski Date: Fri, 19 Jun 2026 11:22:10 +0200 Subject: [PATCH 1/2] feat: duplicate request from history Add a "Duplicate" action to each job row in the Jobs panel. Clicking it selects the job's endpoint and preloads its parameters into the form, so the same request can be tweaked and re-run without rebuilding it after switching views. - getEndpointByApiPath() maps Job.endpointId (the stored API path) back to its registry definition, since path often differs from the registry id (e.g. "aud2video" -> "audio2video", "videos/replace" -> "video-replace") - EndpointForm accepts a one-shot `prefill` (params + nonce); a dedicated effect declared after init/auto-select/auto-default applies it last so model auto-defaults don't clobber restored numeric fields - File fields are skipped (binaries can't be restored); array-mode fields are rebuilt from arrays; nullable fields restore their enabled/disabled state from the stored value Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/page.tsx | 26 +++++++++++++-- src/components/EndpointForm.tsx | 58 ++++++++++++++++++++++++++++++++- src/components/JobsPanel.tsx | 7 +++- src/components/jobs/JobRow.tsx | 12 ++++++- src/lib/endpoint-registry.ts | 12 +++++++ 5 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index c5711f3..05a9b15 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,7 +11,13 @@ import { JobsPanel, JobsPanelRef } from '@/components/JobsPanel'; import { useToast } from '@/components/Toast'; import { useBalance } from '@/components/BalanceContext'; import { useModelsContext } from '@/components/ModelsContext'; -import { EndpointDefinition, JsonValue } from '@/lib/types'; +import { getEndpointByApiPath } from '@/lib/endpoint-registry'; +import { EndpointDefinition, Job, JsonValue } from '@/lib/types'; + +interface FormPrefill { + params: Record; + nonce: number; +} interface ProxyResponse { success: boolean; @@ -22,15 +28,29 @@ interface ProxyResponse { } export default function Home() { - const { showError } = useToast(); + const { showError, showSuccess } = useToast(); const { balance } = useBalance(); const { resolvedTheme, toggleTheme } = useTheme(); const { refreshModels, isLoading: modelsLoading } = useModelsContext(); const jobsPanelRef = useRef(null); const [selectedEndpoint, setSelectedEndpoint] = useState(null); + const [prefill, setPrefill] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isConfigOpen, setIsConfigOpen] = useState(false); + // Duplicate a request from history: select its endpoint and preload its params + // into the form so the user can tweak and re-run without rebuilding from scratch. + const handleDuplicate = (job: Job) => { + const endpoint = getEndpointByApiPath(job.endpointId); + if (!endpoint) { + showError(`Cannot duplicate: unknown endpoint "${job.endpointId}"`); + return; + } + setSelectedEndpoint(endpoint); + setPrefill({ params: job.params, nonce: Date.now() }); + showSuccess(`Loaded "${endpoint.name}" request — review and execute`); + }; + // Auto-open settings drawer when no API token is configured useEffect(() => { fetch('/api/config') @@ -140,6 +160,7 @@ export default function Home() { {selectedEndpoint ? ( jobsPanelRef.current?.refresh()} isSubmitting={isSubmitting} @@ -164,6 +185,7 @@ export default function Home() {
diff --git a/src/components/EndpointForm.tsx b/src/components/EndpointForm.tsx index 156323c..6bf94f1 100644 --- a/src/components/EndpointForm.tsx +++ b/src/components/EndpointForm.tsx @@ -23,14 +23,20 @@ interface ImagePreview { size: number; } +interface FormPrefill { + params: Record; + nonce: number; +} + interface EndpointFormProps { endpoint: EndpointDefinition; + prefill?: FormPrefill | null; onSubmit: (params: Record, formData?: FormData) => void; onPriceCheck?: () => void; isSubmitting: boolean; } -export function EndpointForm({ endpoint, onSubmit, onPriceCheck, isSubmitting }: EndpointFormProps) { +export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubmitting }: EndpointFormProps) { const [values, setValues] = useState>({}); const [files, setFiles] = useState>({}); const [nullableDisabled, setNullableDisabled] = useState>({}); @@ -46,6 +52,7 @@ export function EndpointForm({ endpoint, onSubmit, onPriceCheck, isSubmitting }: const selectedModel = selectedModelSlug ? getModelBySlug(selectedModelSlug) : undefined; const prevModelSlugRef = useRef(undefined); const savedModelsRef = useRef>({}); + const appliedPrefillRef = useRef(undefined); // Resolve lang/voice default values (API returns names, selects use slugs) const resolveLangSlug = useCallback((value: string, model: DeApiModel | undefined): string => { @@ -191,6 +198,55 @@ export function EndpointForm({ endpoint, onSubmit, onPriceCheck, isSubmitting }: }); }, [selectedModelSlug, selectedModel, resolveLangSlug, resolveVoiceSlug]); + // Apply a "duplicate request" prefill — load params from a history job into the form. + // Declared after the init / auto-select / auto-default effects so it runs last and its + // values win. Keyed on a one-shot nonce so it never re-applies on later re-renders. + useEffect(() => { + if (!prefill || prefill.nonce === appliedPrefillRef.current) return; + appliedPrefillRef.current = prefill.nonce; + + const source = prefill.params; + const newValues: Record = {}; + const newNullableDisabled: Record = {}; + const newArrayMode: Record = {}; + + endpoint.params.forEach((param) => { + // Files can't be restored from history — user must re-upload them. + if (param.type === 'file') return; + + let value: JsonValue | undefined = source[param.name]; + // Skip file placeholder strings the proxy logs for multipart fields (e.g. "[File: x.png]"). + if (typeof value === 'string' && value.startsWith('[File:')) value = undefined; + + if (value !== undefined) { + if (param.supportsArray && Array.isArray(value)) { + // Array values came from "array mode" — restore them as newline-separated text. + newValues[param.name] = value.join('\n'); + newArrayMode[param.name] = true; + } else { + newValues[param.name] = value; + } + } else if (param.default !== undefined) { + newValues[param.name] = param.default; + } + + if (param.nullable) { + const v = newValues[param.name]; + newNullableDisabled[param.name] = v === null || v === undefined; + } + }); + + setValues(newValues); + setFiles({}); + setNullableDisabled(newNullableDisabled); + setArrayMode(newArrayMode); + setMultiFileMode({}); + setPriceResult(null); + // Mark the prefilled model as "already applied" so the auto-default effect does not + // overwrite the restored numeric fields (steps/width/height/etc.). + prevModelSlugRef.current = (newValues['model'] as string | undefined) ?? undefined; + }, [prefill, endpoint.params]); + const handleChange = useCallback((name: string, value: JsonValue) => { setValues((prev) => { const newValues = { ...prev, [name]: value }; diff --git a/src/components/JobsPanel.tsx b/src/components/JobsPanel.tsx index ef207ac..ca23ef3 100644 --- a/src/components/JobsPanel.tsx +++ b/src/components/JobsPanel.tsx @@ -13,6 +13,10 @@ export interface JobsPanelRef { selectJob: (jobId: string) => void; } +interface JobsPanelProps { + onDuplicate: (job: Job) => void; +} + interface PollUpdate { timestamp: number; attempt: number; @@ -38,7 +42,7 @@ interface DownloadState { type ViewMode = 'list' | 'logs'; -export const JobsPanel = forwardRef(function JobsPanel(_props, ref) { +export const JobsPanel = forwardRef(function JobsPanel({ onDuplicate }, ref) { const { showError, showSuccess } = useToast(); const { refreshBalance } = useBalance(); const [jobs, setJobs] = useState([]); @@ -408,6 +412,7 @@ export const JobsPanel = forwardRef(function JobsPanel(_pr onOpenResult={handleOpenResult} onDownload={handleDownload} onDelete={handleDelete} + onDuplicate={onDuplicate} /> ))} diff --git a/src/components/jobs/JobRow.tsx b/src/components/jobs/JobRow.tsx index 036939b..e57c475 100644 --- a/src/components/jobs/JobRow.tsx +++ b/src/components/jobs/JobRow.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChevronRight, ExternalLink, Download, Trash2, Loader2 } from 'lucide-react'; +import { ChevronRight, ExternalLink, Download, Trash2, Loader2, Copy } from 'lucide-react'; import { Job, JsonValue } from '@/lib/types'; import { STATUS_BG_COLORS } from '@/lib/constants'; import { formatTime, formatCost, getResultType } from '@/lib/format-utils'; @@ -40,6 +40,7 @@ interface JobRowProps { onOpenResult: (url: string) => void; onDownload: (job: Job, resultUrl: string) => void; onDelete: (jobId: string) => void; + onDuplicate: (job: Job) => void; } export function JobRow({ @@ -54,6 +55,7 @@ export function JobRow({ onOpenResult, onDownload, onDelete, + onDuplicate, }: JobRowProps) { const lastUpdate = activeJob?.pollUpdates[activeJob.pollUpdates.length - 1]; const resultType = getResultType(job.endpointId); @@ -274,6 +276,14 @@ export function JobRow({ )} + +