From 87f4e5e897b19e61b67daa9fd387faff1a6ab0f4 Mon Sep 17 00:00:00 2001 From: Dawid Wenderski Date: Wed, 19 Aug 2026 14:57:27 +0200 Subject: [PATCH 1/3] feat: inline prompt boost, resizable prompt/form, final job price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions to the request form and job list: 1. Inline prompt boost — a "Boost in request" checkbox next to the prompt sends `enhance_prompt` with the generation request, so deAPI boosts the prompt as a pre-step of the job instead of rewriting the field first (the manual Boost button stays). Only /images/generations, /images/edits, /videos/generations and /videos/animations accept the flag; the choice is remembered per endpoint. The boost is billed separately from the inference (the job's own `price` covers inference only), and the /price endpoints do not accept the flag — so the proxy strips it from every price payload and quotes the fee via /prompts/enhancements/price, reporting it alongside the estimate rather than inside it. The boosted prompt comes back on the job status and is shown in a new self-hiding PROMPT BOOST panel (original → boosted, with copy). 2. Prompt field sizing — the prompt textarea fills the form panel (twice the slack of secondary text fields) and can be dragged to a fixed height that is remembered per field, with a reset control. A splitter under the form panel resizes the whole form against the jobs list, also persisted. 3. Final price — deAPI reports `price` {amount, is_estimated} once a request is terminal. It is persisted on the job and merged into the live row, so the badge shows the estimate while the job runs and the charged price after: one figure when they match, estimate struck through next to the final figure when they differ, and a `~` marker while a partner-model charge is still settling. Verified against the live API: enhance_prompt accepted end-to-end, prompt_boosted/prompt_boost/price returned, boost quoted at /prompts/ enhancements/price and charged outside the job price. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/jobs/[id]/route.ts | 39 +++++++ src/app/api/proxy/route.ts | 121 ++++++++++++++++++++- src/app/globals.css | 31 ++++++ src/app/page.tsx | 64 ++++++++++- src/components/EndpointForm.tsx | 111 +++++++++++++++++-- src/components/JobsPanel.tsx | 28 ++++- src/components/form/PromptTextarea.tsx | 99 +++++++++++++++++ src/components/jobs/JobPromptBoost.tsx | 145 +++++++++++++++++++++++++ src/components/jobs/JobRow.tsx | 106 ++++++++++++++++-- src/lib/prompt-enhancement.ts | 26 +++++ src/lib/types.ts | 16 +++ 11 files changed, 762 insertions(+), 24 deletions(-) create mode 100644 src/components/form/PromptTextarea.tsx create mode 100644 src/components/jobs/JobPromptBoost.tsx diff --git a/src/app/api/jobs/[id]/route.ts b/src/app/api/jobs/[id]/route.ts index 6acb997..423b362 100644 --- a/src/app/api/jobs/[id]/route.ts +++ b/src/app/api/jobs/[id]/route.ts @@ -1,5 +1,30 @@ import { loadConfig } from '@/lib/config'; import { updateJob, getJobByRequestId } from '@/lib/storage'; +import { Job } from '@/lib/types'; + +// deAPI reports what a request actually cost on the job status, but only once +// the request is terminal (`price` is null while it runs). For partner models +// the charge settles after inference, so `is_estimated` can still be true — +// keep the flag so the UI can mark the figure as not final. +function readFinalPrice(data: Record | undefined): Job['finalPrice'] { + const price = data?.price as { amount?: unknown; is_estimated?: unknown } | null | undefined; + if (!price || typeof price.amount !== 'number') return undefined; + return { amount: price.amount, isEstimated: price.is_estimated === true }; +} + +// Inline prompt booster (`enhance_prompt`) outcome. `prompt_boost` is non-null +// only once the boost has actually run. +function readPromptBoost(data: Record | undefined): Job['promptBoost'] { + const boost = data?.prompt_boost as Record | null | undefined; + if (!boost) return undefined; + const str = (v: unknown) => (typeof v === 'string' ? v : null); + return { + prompt: str(boost.prompt), + promptOriginal: str(boost.prompt_original), + negativePrompt: str(boost.negative_prompt), + negativePromptOriginal: str(boost.negative_prompt_original), + }; +} // GET /api/jobs/[id] - One-shot job status fetch + persist. // @@ -47,12 +72,24 @@ export async function GET( const job = getJobByRequestId(requestId); if (job) { const status = data.data?.status || data.status; + // Price and boost details ride along with every status payload; they are + // only populated once the request is terminal. + const finalPrice = readFinalPrice(data.data); + const promptBoost = readPromptBoost(data.data); + const boostFields: Partial = {}; + if (finalPrice) boostFields.finalPrice = finalPrice; + if (data.data?.prompt_boosted !== undefined) { + boostFields.promptBoosted = data.data.prompt_boosted === true; + } + if (promptBoost) boostFields.promptBoost = promptBoost; + if (status === 'done') { const updateData: Record = { status: 'completed', rawResponse: data, resultUrl: data.data?.result_url, completedAt: new Date().toISOString(), + ...boostFields, }; if (data.data?.cost_credits !== undefined) { updateData.costCredits = data.data.cost_credits; @@ -69,6 +106,7 @@ export async function GET( data.error || (data.data?.error_code ? `Error: ${data.data.error_code}` : undefined), completedAt: new Date().toISOString(), + ...boostFields, }); } else { // A queued job (pending/in_queue/waiting) stays 'pending' (waiting) — it @@ -84,6 +122,7 @@ export async function GET( updateJob(job.id, { status: jobStatus, rawResponse: data, + ...boostFields, }); } } diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 654445a..915fff2 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -4,8 +4,59 @@ import { addJob, generateJobId, updateJob } from '@/lib/storage'; import { saveUploadedFile } from '@/lib/upload-storage'; import { getEndpointById } from '@/lib/endpoint-registry'; import { fetchAllPages, PAGE_LIMIT } from '@/lib/pagination'; +import { + INLINE_BOOST_FIELD, + enhancementTypeForPath, + supportsInlineBoost, +} from '@/lib/prompt-enhancement'; import { Job, JsonValue, UploadedFile } from '@/lib/types'; +// Price of the inline prompt boost (`enhance_prompt`), quoted by the same +// endpoint the standalone booster uses. The generation /price endpoints do not +// accept `enhance_prompt` and the job's own `price` covers the inference only, +// so the boost fee is quoted here and reported alongside the estimate. +async function fetchInlineBoostPrice( + apiUrl: string, + apiToken: string, + args: { + type: string; + modelSlug: string; + prompt: string; + negativePrompt?: string; + image?: File; + } +): Promise { + try { + const form = new FormData(); + form.append('type', args.type); + form.append('model_slug', args.modelSlug); + form.append('prompt', args.prompt); + if (args.negativePrompt && args.negativePrompt.length >= 3) { + form.append('negative_prompt', args.negativePrompt); + } + if (args.image) form.append('image', args.image); + + const res = await fetch(`${apiUrl.replace(/\/$/, '')}/prompts/enhancements/price`, { + method: 'POST', + headers: { Authorization: `Bearer ${apiToken}` }, + body: form, + }); + const text = await res.text(); + let data: { price?: number; data?: { price?: number } } | null; + try { + data = JSON.parse(text); + } catch { + data = null; + } + console.log('[deapi-tester] Boost price response:', res.status, data ?? text.slice(0, 300)); + const price = data?.price ?? data?.data?.price; + return res.ok && typeof price === 'number' ? price : undefined; + } catch (err) { + console.error('[deapi-tester] Boost price calculation failed:', err); + return undefined; + } +} + // POST /api/proxy - Proxy request to deAPI export async function POST(request: Request) { // Track a persisted job so the outer catch can mark it failed instead of @@ -65,6 +116,18 @@ export async function POST(request: Request) { ); } + // Inline prompt booster: `enhance_prompt` travels with the generation + // request itself. The /price endpoints do not accept it, so it is stripped + // from every price payload and the boost fee is quoted separately (below). + const boostFlag = params[INLINE_BOOST_FIELD]; + const boostRequested = + supportsInlineBoost(endpoint.path) && + (boostFlag === true || boostFlag === 'true' || boostFlag === '1' || boostFlag === 1); + if (isPriceCalc && boostFlag !== undefined) { + delete params[INLINE_BOOST_FIELD]; + formData?.delete(INLINE_BOOST_FIELD); + } + // For price calculation, check if endpoint supports it if (isPriceCalc && (!endpoint.hasPriceCalc || !endpoint.priceCalcPath)) { return NextResponse.json( @@ -180,6 +243,7 @@ export async function POST(request: Request) { // Fetch estimated price if endpoint supports price calculation (skip if this IS a price calc request) let estimatedPrice: number | undefined; + let estimateBreakdown: { base?: number; boost?: number } | undefined; if (!isPriceCalc && endpoint.hasPriceCalc && endpoint.priceCalcPath) { try { const priceUrl = config.apiUrl.replace(/\/$/, '') + endpoint.priceCalcPath; @@ -196,6 +260,7 @@ export async function POST(request: Request) { const priceForm = new FormData(); for (const [key, value] of Object.entries(params)) { if (fileEntries.some((f) => f.field === key)) continue; // skip file placeholders + if (key === INLINE_BOOST_FIELD) continue; // not part of the /price contract if (value !== undefined && value !== null) { priceForm.append(key, String(value)); } @@ -209,13 +274,15 @@ export async function POST(request: Request) { body: priceForm, }); } else { + const priceParams = { ...params }; + delete priceParams[INLINE_BOOST_FIELD]; // not part of the /price contract priceResponse = await fetch(priceUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${config.apiToken}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(params), + body: JSON.stringify(priceParams), }); } @@ -244,6 +311,29 @@ export async function POST(request: Request) { } } + // The inline boost is charged separately from the inference: the job's + // reported `price` covers the inference only (verified against the live + // API). So quote the boost on the side and keep it out of the estimate the + // final price is compared against, rather than folding it in. + if (!isPriceCalc && boostRequested) { + const boostType = enhancementTypeForPath(endpoint.path); + const modelSlug = typeof params.model === 'string' ? params.model : undefined; + const promptText = typeof params.prompt === 'string' ? params.prompt : undefined; + if (boostType && modelSlug && promptText) { + const boostPrice = await fetchInlineBoostPrice(config.apiUrl, config.apiToken, { + type: boostType, + modelSlug, + prompt: promptText, + negativePrompt: + typeof params.negative_prompt === 'string' ? params.negative_prompt : undefined, + image: fileEntries.find((f) => f.file.type.startsWith('image/'))?.file, + }); + if (boostPrice !== undefined) { + estimateBreakdown = { base: estimatedPrice, boost: boostPrice }; + } + } + } + // 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. @@ -279,6 +369,7 @@ export async function POST(request: Request) { status: 'pending', createdAt: new Date().toISOString(), costCredits: estimatedPrice, + estimateBreakdown, }; if (!isPriceCalc) { if (providedJobId) { @@ -412,6 +503,34 @@ export async function POST(request: Request) { }); } + // Price-only check with the inline boost enabled: the /price endpoint quotes + // the inference alone, so quote the boost as well and expose base/boost/total + // under `_tester`. The API payload itself stays verbatim. + if (isPriceCalc && boostRequested && responseOk) { + const basePrice = rawResponse?.data?.price ?? rawResponse?.price; + const boostType = enhancementTypeForPath(endpoint.path); + const modelSlug = typeof params.model === 'string' ? params.model : undefined; + const promptText = typeof params.prompt === 'string' ? params.prompt : undefined; + if (boostType && modelSlug && promptText) { + const boostPrice = await fetchInlineBoostPrice(config.apiUrl, config.apiToken, { + type: boostType, + modelSlug, + prompt: promptText, + negativePrompt: + typeof params.negative_prompt === 'string' ? params.negative_prompt : undefined, + image: fileEntries.find((f) => f.file.type.startsWith('image/'))?.file, + }); + if (boostPrice !== undefined) { + rawResponse._tester = { + ...(rawResponse._tester || {}), + base_price: basePrice, + boost_price: boostPrice, + total_price: (typeof basePrice === 'number' ? basePrice : 0) + boostPrice, + }; + } + } + } + // For sync endpoints, mark as completed // Only update costCredits if API returns it, otherwise keep the estimated price const syncUpdateData: Record = { diff --git a/src/app/globals.css b/src/app/globals.css index 61c9526..6903478 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -142,6 +142,37 @@ body { height: 48px; } +/* Horizontal splitter — drag to resize the panel above it */ +.resize-handle-row { + height: 6px; + background: transparent; + cursor: row-resize; + transition: background 0.15s; + position: relative; +} + +.resize-handle-row::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + height: 2px; + width: 32px; + background: var(--border); + border-radius: 1px; + transition: background 0.15s, width 0.15s; +} + +.resize-handle-row:hover { + background: var(--accent-dim); +} + +.resize-handle-row:hover::after { + background: var(--accent); + width: 64px; +} + /* Drawer animation */ @keyframes slideIn { from { diff --git a/src/app/page.tsx b/src/app/page.tsx index 368ae1e..4e4edd8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,6 +21,10 @@ interface FormPrefill { nonce: number; } +// Request form panel sizing (dragged by the splitter below the form). +const FORM_HEIGHT_KEY = 'deapi-form-height'; +const FORM_MIN_HEIGHT = 200; + interface ProxyResponse { success: boolean; jobId?: string; @@ -39,6 +43,48 @@ export default function Home() { const [prefill, setPrefill] = useState(null); const [isConfigOpen, setIsConfigOpen] = useState(false); + // Height of the request form panel. Null = the original auto behaviour + // (min 200px, capped at 45vh); a number is a height the user dragged and is + // remembered across sessions, so a long prompt can get as much room as needed. + const [formHeight, setFormHeight] = useState(null); + const formAreaRef = useRef(null); + + useEffect(() => { + const stored = Number(localStorage.getItem(FORM_HEIGHT_KEY)); + if (Number.isFinite(stored) && stored >= FORM_MIN_HEIGHT) setFormHeight(stored); + }, []); + + const startFormResize = (e: React.PointerEvent) => { + e.preventDefault(); + const startY = e.clientY; + const startHeight = formAreaRef.current?.getBoundingClientRect().height ?? FORM_MIN_HEIGHT; + let latest = startHeight; + + const onMove = (ev: PointerEvent) => { + // Leave room for the jobs panel below, whatever the viewport height is. + const max = Math.max(FORM_MIN_HEIGHT, window.innerHeight - 200); + latest = Math.min(max, Math.max(FORM_MIN_HEIGHT, startHeight + ev.clientY - startY)); + setFormHeight(latest); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + localStorage.setItem(FORM_HEIGHT_KEY, String(Math.round(latest))); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + }; + + const resetFormHeight = () => { + setFormHeight(null); + localStorage.removeItem(FORM_HEIGHT_KEY); + }; + // 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) => { @@ -189,7 +235,15 @@ export default function Home() { {/* Center: Form + Jobs stacked */}
{/* Form Area */} -
+
{selectedEndpoint ? ( + {/* Splitter: drag to give the form (and its prompt fields) more room */} +
+ {/* Jobs Panel - takes remaining space */}
>({}); const [imagePreviews, setImagePreviews] = useState>({}); const [isCheckingPrice, setIsCheckingPrice] = useState(false); - const [priceResult, setPriceResult] = useState<{ credits: number; error?: string } | null>(null); + const [priceResult, setPriceResult] = useState<{ + credits: number; + boostCredits?: number; + error?: string; + } | null>(null); const [isBoosting, setIsBoosting] = useState(false); const { models, isLoading: modelsLoading, getModelBySlug } = useModelsContext(); const { showError, showSuccess } = useToast(); @@ -67,6 +78,36 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm ? 'caption' : null; const canBoost = !!enhancementType && !!enhanceableField; + + // Inline boost: instead of rewriting the prompt in the form first, the request + // carries `enhance_prompt: true` and deAPI boosts it as a pre-step of the job. + // Only four endpoints accept the flag. The choice is remembered per endpoint. + const canInlineBoost = supportsInlineBoost(endpoint.path); + const [inlineBoost, setInlineBoost] = useState(false); + const inlineBoostStorageKey = `deapi-inline-boost:${endpoint.id}`; + useEffect(() => { + if (!canInlineBoost) { + setInlineBoost(false); + return; + } + setInlineBoost(localStorage.getItem(inlineBoostStorageKey) === '1'); + }, [canInlineBoost, inlineBoostStorageKey]); + + const toggleInlineBoost = (enabled: boolean) => { + setInlineBoost(enabled); + localStorage.setItem(inlineBoostStorageKey, enabled ? '1' : '0'); + }; + + // Inline boost flag as the API expects it. Laravel's `boolean` rule accepts + // 1/0 but not the string "true", so multipart requests send "1". + const inlineBoostPayload = useCallback( + (contentType: 'json' | 'multipart'): Record => + canInlineBoost && inlineBoost + ? { [INLINE_BOOST_FIELD]: contentType === 'multipart' ? '1' : true } + : {}, + [canInlineBoost, inlineBoost] + ); + const prevModelSlugRef = useRef(undefined); const savedModelsRef = useRef>({}); const appliedPrefillRef = useRef(undefined); @@ -787,6 +828,11 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm const formData = new FormData(); formData.append('_endpointId', endpoint.id); formData.append('_priceCalc', 'true'); + // The /price endpoints do not take `enhance_prompt` — the proxy strips it + // and quotes the boost separately, so the number includes the boost fee. + Object.entries(inlineBoostPayload('multipart')).forEach(([key, value]) => { + formData.append(key, String(value)); + }); Object.entries(filteredValues).forEach(([key, value]) => { if (value !== undefined && value !== '') { @@ -811,6 +857,7 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...filteredValues, + ...inlineBoostPayload('json'), _endpointId: endpoint.id, _priceCalc: true, }), @@ -825,8 +872,14 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm } // Most price endpoints return { data: { price } }; prompt-enhancement returns top-level { price }. + // With the inline boost on, the proxy quotes the boost separately under + // `_tester` — it is charged on its own, so show it as an add-on rather + // than folding it into the inference price. const price = data.rawResponse?.data?.price ?? data.rawResponse?.price ?? 0; - setPriceResult({ credits: price }); + const tester = data.rawResponse?._tester as + | { boost_price?: number; total_price?: number } + | undefined; + setPriceResult({ credits: price, boostCredits: tester?.boost_price }); onPriceCheck?.(); } catch (err) { setPriceResult({ credits: 0, error: err instanceof Error ? err.message : 'Failed' }); @@ -843,6 +896,9 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm if (endpoint.contentType === 'multipart') { const formData = new FormData(); formData.append('_endpointId', endpoint.id); + Object.entries(inlineBoostPayload('multipart')).forEach(([key, value]) => { + formData.append(key, String(value)); + }); Object.entries(filteredValues).forEach(([key, value]) => { if (value !== undefined && value !== '') { @@ -866,9 +922,13 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm } }); - onSubmit(filteredValues, formData); + onSubmit({ ...filteredValues, ...inlineBoostPayload('multipart') }, formData); } else { - onSubmit({ ...filteredValues, _endpointId: endpoint.id }); + onSubmit({ + ...filteredValues, + ...inlineBoostPayload('json'), + _endpointId: endpoint.id, + }); } }; @@ -899,6 +959,14 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm {priceResult && ( {priceResult.error ? priceResult.error : `~$${priceResult.credits}`} + {!priceResult.error && priceResult.boostCredits !== undefined && ( + + + boost ${formatCost(priceResult.boostCredits)} + + )} )} @@ -962,7 +1030,14 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm )} {promptParams.map((param) => ( -
+ // The primary prompt gets twice the slack of any secondary text + // field, so dragging the form taller mostly grows the prompt. +
-