diff --git a/.gitignore b/.gitignore index 3853257..56b8cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ next-env.d.ts # local data files (contain sensitive keys and user history) /data/config.json /data/history.json +/data/uploads # local vscode settings .vscode/settings.json \ No newline at end of file diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index a3de5bb..01a54cf 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; import { loadConfig } from '@/lib/config'; import { addJob, generateJobId, updateJob } from '@/lib/storage'; +import { saveUploadedFile } from '@/lib/upload-storage'; import { getEndpointById } from '@/lib/endpoint-registry'; -import { Job, JsonValue } from '@/lib/types'; +import { Job, JsonValue, UploadedFile } from '@/lib/types'; // POST /api/proxy - Proxy request to deAPI export async function POST(request: Request) { @@ -11,6 +12,7 @@ export async function POST(request: Request) { let endpointId: string; let params: Record; let formData: FormData | null = null; + const fileEntries: { field: string; file: File }[] = []; // Parse request based on content type if (contentType.includes('multipart/form-data')) { @@ -24,6 +26,7 @@ export async function POST(request: Request) { params[key] = value; } else if (value instanceof File) { params[key] = `[File: ${value.name}]`; + fileEntries.push({ field: key, file: value }); } }); } else { @@ -161,6 +164,18 @@ export async function POST(request: Request) { } } + // Persist uploaded files (content-addressed) so the request can be duplicated + // later with its files intact. Skip for price-only requests. Reading a File's + // bytes does not consume it, so formData is still sent to deAPI below. + let uploadedFiles: UploadedFile[] | undefined; + if (!isPriceCalc && fileEntries.length > 0) { + uploadedFiles = []; + for (const { field, file } of fileEntries) { + const buffer = Buffer.from(await file.arrayBuffer()); + uploadedFiles.push(saveUploadedFile(buffer, file.name, file.type, field)); + } + } + // Create job entry before making request const jobId = generateJobId(); // Store the actual API path (without leading slash) as endpointId @@ -176,6 +191,7 @@ export async function POST(request: Request) { headers: { ...headers, Authorization: 'Bearer ***' }, // Mask token in logs body: bodyForLog, }, + uploadedFiles, status: 'pending', createdAt: new Date().toISOString(), costCredits: estimatedPrice, diff --git a/src/app/api/uploads/[name]/route.ts b/src/app/api/uploads/[name]/route.ts new file mode 100644 index 0000000..fd88413 --- /dev/null +++ b/src/app/api/uploads/[name]/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { getUploadPath } from '@/lib/upload-storage'; +import * as fs from 'fs'; + +// GET /api/uploads/[name] - Serve a persisted upload by its stored name. +// Used to restore files into the form when duplicating a multipart request. +export async function GET( + _request: Request, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const decoded = decodeURIComponent(name); + + const filePath = getUploadPath(decoded); + if (!filePath) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }); + } + + const buffer = fs.readFileSync(filePath); + // Content-Type is intentionally generic; the client rebuilds the File with the + // correct mimeType from the job's uploadedFiles metadata. + return new NextResponse(buffer, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': buffer.length.toString(), + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch (error) { + console.error('[deapi-tester] GET /api/uploads/[name] error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c5711f3..4eb6689 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,7 +11,14 @@ 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, UploadedFile } from '@/lib/types'; + +interface FormPrefill { + params: Record; + uploadedFiles?: UploadedFile[]; + nonce: number; +} interface ProxyResponse { success: boolean; @@ -22,15 +29,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, uploadedFiles: job.uploadedFiles, 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 +161,7 @@ export default function Home() { {selectedEndpoint ? ( jobsPanelRef.current?.refresh()} isSubmitting={isSubmitting} @@ -164,6 +186,7 @@ export default function Home() {
diff --git a/src/components/EndpointForm.tsx b/src/components/EndpointForm.tsx index 156323c..e05b444 100644 --- a/src/components/EndpointForm.tsx +++ b/src/components/EndpointForm.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Loader2, CircleDollarSign, Play, ChevronRight, RotateCcw, Dices } from 'lucide-react'; -import { EndpointDefinition, EndpointParam, JsonValue, DeApiModel } from '@/lib/types'; +import { EndpointDefinition, EndpointParam, JsonValue, DeApiModel, UploadedFile } from '@/lib/types'; import { useModelsContext } from '@/components/ModelsContext'; import { ModelInfo } from '@/components/ModelInfo'; import { FormField } from '@/components/form/FormField'; @@ -23,14 +23,21 @@ interface ImagePreview { size: number; } +interface FormPrefill { + params: Record; + uploadedFiles?: UploadedFile[]; + 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 +53,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 +199,114 @@ 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); + setImagePreviews((prev) => { + Object.values(prev).flat().forEach((p) => URL.revokeObjectURL(p.url)); + return {}; + }); + // 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; + + // Restore persisted multipart files (async) so duplicated requests keep uploads. + const uploaded = prefill.uploadedFiles; + if (uploaded && uploaded.length > 0) { + const thisNonce = prefill.nonce; + const fileParamsList = endpoint.params.filter((p) => p.type === 'file'); + (async () => { + const restoredFiles: Record = {}; + const restoredPreviews: Record = {}; + const restoredMultiMode: Record = {}; + + for (const param of fileParamsList) { + const matches = uploaded.filter( + (u) => u.field === param.name || (!!param.multiFieldName && u.field === param.multiFieldName) + ); + if (matches.length === 0) continue; + + const isMulti = + matches.length > 1 || + (!!param.multiFieldName && matches.some((m) => m.field === param.multiFieldName)); + + const fetched: File[] = []; + for (const m of matches) { + try { + const res = await fetch(`/api/uploads/${encodeURIComponent(m.storedName)}`); + if (!res.ok) continue; + const blob = await res.blob(); + fetched.push(new File([blob], m.fileName, { type: m.mimeType })); + } catch { + // skip files that can't be restored + } + } + if (fetched.length === 0) continue; + // A newer prefill superseded this one mid-fetch — abandon stale work + if (appliedPrefillRef.current !== thisNonce) return; + + restoredFiles[param.name] = isMulti ? fetched : fetched[0]; + if (isMulti && param.multiFieldName) restoredMultiMode[param.name] = true; + + const images = fetched.filter((f) => f.type.startsWith('image/')); + if (images.length > 0) { + restoredPreviews[param.name] = await Promise.all(images.map(generateImagePreview)); + } + } + + if (appliedPrefillRef.current !== thisNonce) return; + if (Object.keys(restoredFiles).length > 0) setFiles(restoredFiles); + if (Object.keys(restoredPreviews).length > 0) { + setImagePreviews((prev) => ({ ...prev, ...restoredPreviews })); + } + if (Object.keys(restoredMultiMode).length > 0) { + setMultiFileMode((prev) => ({ ...prev, ...restoredMultiMode })); + } + })(); + } + }, [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({ )} + +