Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 17 additions & 1 deletion src/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -11,6 +12,7 @@ export async function POST(request: Request) {
let endpointId: string;
let params: Record<string, JsonValue>;
let formData: FormData | null = null;
const fileEntries: { field: string; file: File }[] = [];

// Parse request based on content type
if (contentType.includes('multipart/form-data')) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/app/api/uploads/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
27 changes: 25 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonValue>;
uploadedFiles?: UploadedFile[];
nonce: number;
}

interface ProxyResponse {
success: boolean;
Expand All @@ -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<JobsPanelRef>(null);
const [selectedEndpoint, setSelectedEndpoint] = useState<EndpointDefinition | null>(null);
const [prefill, setPrefill] = useState<FormPrefill | null>(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')
Expand Down Expand Up @@ -140,6 +161,7 @@ export default function Home() {
{selectedEndpoint ? (
<EndpointForm
endpoint={selectedEndpoint}
prefill={prefill}
onSubmit={handleSubmit}
onPriceCheck={() => jobsPanelRef.current?.refresh()}
isSubmitting={isSubmitting}
Expand All @@ -164,6 +186,7 @@ export default function Home() {
<div className="flex-1 min-h-0 overflow-hidden">
<JobsPanel
ref={jobsPanelRef}
onDuplicate={handleDuplicate}
/>
</div>
</div>
Expand Down
120 changes: 118 additions & 2 deletions src/components/EndpointForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,14 +23,21 @@ interface ImagePreview {
size: number;
}

interface FormPrefill {
params: Record<string, JsonValue>;
uploadedFiles?: UploadedFile[];
nonce: number;
}

interface EndpointFormProps {
endpoint: EndpointDefinition;
prefill?: FormPrefill | null;
onSubmit: (params: Record<string, JsonValue>, 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<Record<string, JsonValue>>({});
const [files, setFiles] = useState<Record<string, File | File[]>>({});
const [nullableDisabled, setNullableDisabled] = useState<Record<string, boolean>>({});
Expand All @@ -46,6 +53,7 @@ export function EndpointForm({ endpoint, onSubmit, onPriceCheck, isSubmitting }:
const selectedModel = selectedModelSlug ? getModelBySlug(selectedModelSlug) : undefined;
const prevModelSlugRef = useRef<string | undefined>(undefined);
const savedModelsRef = useRef<Record<string, string>>({});
const appliedPrefillRef = useRef<number | undefined>(undefined);

// Resolve lang/voice default values (API returns names, selects use slugs)
const resolveLangSlug = useCallback((value: string, model: DeApiModel | undefined): string => {
Expand Down Expand Up @@ -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<string, JsonValue> = {};
const newNullableDisabled: Record<string, boolean> = {};
const newArrayMode: Record<string, boolean> = {};

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<string, File | File[]> = {};
const restoredPreviews: Record<string, ImagePreview[]> = {};
const restoredMultiMode: Record<string, boolean> = {};

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 };
Expand Down
7 changes: 6 additions & 1 deletion src/components/JobsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export interface JobsPanelRef {
selectJob: (jobId: string) => void;
}

interface JobsPanelProps {
onDuplicate: (job: Job) => void;
}

interface PollUpdate {
timestamp: number;
attempt: number;
Expand All @@ -38,7 +42,7 @@ interface DownloadState {

type ViewMode = 'list' | 'logs';

export const JobsPanel = forwardRef<JobsPanelRef, object>(function JobsPanel(_props, ref) {
export const JobsPanel = forwardRef<JobsPanelRef, JobsPanelProps>(function JobsPanel({ onDuplicate }, ref) {
const { showError, showSuccess } = useToast();
const { refreshBalance } = useBalance();
const [jobs, setJobs] = useState<Job[]>([]);
Expand Down Expand Up @@ -408,6 +412,7 @@ export const JobsPanel = forwardRef<JobsPanelRef, object>(function JobsPanel(_pr
onOpenResult={handleOpenResult}
onDownload={handleDownload}
onDelete={handleDelete}
onDuplicate={onDuplicate}
/>
))}
</div>
Expand Down
12 changes: 11 additions & 1 deletion src/components/jobs/JobRow.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -274,6 +276,14 @@ export function JobRow({
</>
)}

<button
onClick={() => onDuplicate(job)}
className="p-1.5 text-[var(--muted)] hover:text-blue-400 transition-colors"
title="Duplicate request — load these parameters into the form"
>
<Copy className="w-3.5 h-3.5" />
</button>

<button
onClick={() => onDelete(job.id)}
className="p-1.5 text-[var(--muted)] hover:text-red-400 transition-colors"
Expand Down
12 changes: 12 additions & 0 deletions src/lib/endpoint-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,3 +985,15 @@ export function getEndpointsByGroup(group: string): EndpointDefinition[] {
export function getEndpointById(id: string): EndpointDefinition | undefined {
return ENDPOINTS.find(e => e.id === id);
}

// Helper: get endpoint by the value stored as Job.endpointId.
// The proxy stores the API path without its leading slash (e.g. "aud2video",
// "videos/replace"), which often differs from the registry id (e.g. "audio2video",
// "video-replace"). Used to map a history job back to its endpoint definition.
export function getEndpointByApiPath(apiPath: string): EndpointDefinition | undefined {
const normalized = apiPath.replace(/^\//, '');
const byPath = ENDPOINTS.find(e => e.path.replace(/^\//, '') === normalized);
if (byPath) return byPath;
// Fallback: some jobs may have been stored using the registry id directly
return ENDPOINTS.find(e => e.id === normalized);
}
Loading
Loading