import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Button, Card, Checkbox, Dialog, Flex, Grid, Inline, Label, Select, Spinner, Stack, Tab, TabList, TabPanel, Text, TextArea, TextInput, } from '@sanity/ui'; import { BoltIcon, CheckmarkCircleIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon, ResetIcon, SearchIcon, UploadIcon, } from '@sanity/icons'; import type { AssetFromSource, AssetSourceComponentProps } from 'sanity'; import { useFormValue, useSchema } from 'sanity'; import { useSanityAssets } from '../lib/useSanityAssets.js'; import { AssetPickerGrid } from './AssetPickerGrid.js'; import { AppPickerPanel } from './AppPickerPanel.js'; import { MissingInputsForm } from './MissingInputsForm.js'; import type { AssetTypeFilter, LaminaAsset, LaminaPreset } from '../types.js'; import type { AppSummary as SdkAppSummary, CostEstimate, ContentBriefParams, ContentConcept, ExecutionOutput, ExecutionStatus, MissingInput, PreviewRunResult, PreviewAppMode, PreviewFreestyleMode, PreviewAgentFailedMode, FormField, RunConfirmedParams, } from '@uselamina/sdk'; import { LaminaAuthError, LaminaRateLimitError } from '@uselamina/sdk'; // SDK 0.2.0 has native `progress`, `inputSummary`, `modality`, `icon` fields on // AppSummary / ExecutionStatus, so we no longer need extension interfaces for // those. The one remaining augmentation: `credits.manageUrl` on CostEstimate // hasn't shipped to the SDK yet. interface CostEstimateWithManageUrl extends CostEstimate { credits?: { manageUrl?: string }; } import { useLamina } from '../lib/LaminaContext.js'; import { getRoutedAppId, saveRoutedAppId, clearRoutedAppId } from '../lib/appRouting.js'; import { clearRecentBriefs, getRecentBriefs, saveRecentBrief } from '../lib/recentBriefs.js'; import { detectAspectRatio, ASPECT_RATIO_OPTIONS } from '../lib/aspectRatio.js'; import type { LaminaAspectRatio } from '../lib/aspectRatio.js'; import type { GeneratedOutput, GenerationState } from '../types.js'; import type { EnhanceResult } from '../lib/briefEnhancer.js'; import { useDocumentBrief } from '../lib/useDocumentBrief.js'; import { classifyRunFailure } from '../lib/classifyGenerationError.js'; import { clearDialogState, patchDialogState, readDialogState, type CachedRunMode, type PreviewCache, type RunCache, } from '../lib/dialogStore.js'; const MODALITIES = [ { value: '', label: 'Auto-detect' }, { value: 'image', label: 'Image' }, { value: 'video', label: 'Video' }, ] as const; /** Built-in presets for common field names. Custom presets override these. */ const DEFAULT_PRESETS: Record = { ogImage: { aspectRatio: '16:9', modality: 'image' }, socialImage: { aspectRatio: '16:9', modality: 'image' }, storyImage: { aspectRatio: '9:16', modality: 'image' }, thumbnail: { aspectRatio: '1:1', modality: 'image' }, avatar: { aspectRatio: '1:1', modality: 'image' }, }; /** * Resolve a preset for the current field. * Custom presets from plugin config take precedence over built-in defaults. * Returns `[presetName, preset]` or `null` if no match. */ function resolvePreset( fieldName: string | undefined, customPresets: Record | undefined, ): [string, LaminaPreset] | null { if (!fieldName) return null; // Custom presets override defaults if (customPresets?.[fieldName]) { return [fieldName, customPresets[fieldName]]; } if (DEFAULT_PRESETS[fieldName]) { return [fieldName, DEFAULT_PRESETS[fieldName]]; } return null; } /** 30 minutes in milliseconds. */ const GENERATION_TIMEOUT_MS = 30 * 60 * 1000; /** Show a warning 5 minutes before timeout. */ const TIMEOUT_WARNING_MS = GENERATION_TIMEOUT_MS - 5 * 60 * 1000; /** Prompt suggestions by schema type. */ const SCHEMA_SUGGESTIONS: Record = { product: [ 'Product photo on clean white background', 'Lifestyle shot showing product in use', 'Social media ad with product highlight', ], blogPost: [ 'Blog header illustration matching the topic', 'Social share image with title overlay', ], article: [ 'Article header image', 'Social share card for article', ], landingPage: [ 'Hero banner for landing page', 'Feature section illustration', ], page: [ 'Page hero banner', 'Section background image', ], }; const DEFAULT_IMAGE_SUGGESTIONS = [ 'Product photo', 'Marketing banner', 'Social media post', ]; const DEFAULT_VIDEO_SUGGESTIONS = [ 'Product demo video', 'Social media reel', 'Promotional clip', ]; function getSuggestions(documentType: string | undefined, assetType: 'image' | 'file'): string[] { if (documentType) { const match = SCHEMA_SUGGESTIONS[documentType]; if (match) return match; // Try lowercase match const lower = documentType.toLowerCase(); for (const [key, suggestions] of Object.entries(SCHEMA_SUGGESTIONS)) { if (lower.includes(key.toLowerCase())) return suggestions; } } return assetType === 'file' ? DEFAULT_VIDEO_SUGGESTIONS : DEFAULT_IMAGE_SUGGESTIONS; } interface BrandProfileEntry { id: string; name: string; } interface CampaignEntry { id: string; name: string; } interface NeedsInputContext { message: string; missing: MissingInput[]; appId?: string; workflowId?: string; } interface AppEntry { appId: string; name: string; description: string | null; capabilities: SdkAppSummary['capabilities']; icon?: string | null; modality?: string | null; inputSummary?: string | null; /** Optional empty-state media (image/video URL) — when present, picker * renders a card with the thumbnail instead of a plain list row. */ thumbnail?: { url: string; type: 'image' | 'video' } | null; } interface AppPickerState { expanded: boolean; loading: boolean; apps: AppEntry[]; error: string | null; mode: 'list' | 'discover'; } function toGeneratedOutput(out: ExecutionOutput): GeneratedOutput | null { if (out.status !== 'completed' || !out.value || typeof out.value !== 'string') { return null; } return { id: out.id, type: out.type, url: out.value, mimeType: out.mimeType ?? null, label: out.label, dimensions: out.dimensions ?? null, durationSeconds: out.durationSeconds ?? null, }; } function failureMessageFromRun(run: ExecutionStatus): string { const parentError = typeof run.errorMessage === 'string' ? run.errorMessage.trim() : ''; if (parentError) return parentError; const outputError = (run.outputs ?? []) .map((output) => (typeof output.error === 'string' ? output.error.trim() : '')) .find(Boolean); return outputError || 'Generation failed.'; } function progressFromStatus(status: ExecutionStatus): number | null { // SDK 0.2.0 provides native progress.percentComplete (number | null). if (typeof status.progress?.percentComplete === 'number') { return Math.round(status.progress.percentComplete); } // Fallback for older API responses without granular progress switch (status.status) { case 'queued': return 0; case 'running': return 0; case 'completed': return 100; case 'failed': return null; default: return null; } } /** * Merge a freshly-observed progress value with the previously-displayed one, * never going backward. Industry-standard loader behavior — once the user * has seen "30%" we don't show them "10%" again, even if the server's notion * of progress dipped (e.g. queued→running transition reports 0% momentarily). * * Keeps null only when both are null. A real number always wins over null. */ function monotonicProgress(prev: number | null, next: number | null): number | null { if (next == null) return prev; if (prev == null) return next; return next > prev ? next : prev; } function describeError(err: unknown): string { if (err instanceof LaminaAuthError) { return 'Invalid or expired API key. Please check your Lamina API key configuration.'; } if (err instanceof LaminaRateLimitError) { const wait = err.retryAfterSeconds; return wait != null && wait > 0 ? `Rate limited. Please wait ${wait} seconds before trying again.` : 'Rate limited. Please wait a moment before trying again.'; } if ( err instanceof TypeError && typeof err.message === 'string' && err.message.toLowerCase().includes('fetch') ) { return 'Network error. Please check your connection.'; } if (err instanceof Error) { return err.message; } return 'An unexpected error occurred.'; } function isMediaParam(param: MissingInput): boolean { return ( param.type === 'url' && Array.isArray(param.accept) && param.accept.some((a) => a === 'image' || a === 'video') ); } function MediaInputField({ param, value, onChange, laminaClient, }: { param: MissingInput; value: unknown; onChange: (name: string, value: unknown) => void; laminaClient: ReturnType['client']; }) { const label = param.description || param.name; const [uploading, setUploading] = useState(false); const [preview, setPreview] = useState(null); const fileRef = useRef(null); const handleFile = useCallback( async (file: File) => { setUploading(true); try { // Show local preview immediately setPreview(URL.createObjectURL(file)); // Upload via transferAsset to get a CDN URL const mediaType = file.type.startsWith('video/') ? 'video' : 'image'; const result = await laminaClient.publishing.transferAsset({ sourceUrl: URL.createObjectURL(file), mediaType: mediaType as 'image' | 'video', filename: file.name, }); onChange(param.name, result.data.cdnUrl); } catch { // Fall back: create a blob URL (won't work for server-side, but shows intent) onChange(param.name, URL.createObjectURL(file)); } finally { setUploading(false); } }, [laminaClient, param.name, onChange], ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) handleFile(file); }, [handleFile], ); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) handleFile(file); }, [handleFile], ); const currentUrl = typeof value === 'string' ? value : null; return ( {currentUrl || preview ? ( {uploading ? 'Uploading...' : 'Image attached'}