import type { SuperagentToolCall } from '../types'; import { pluralize } from './searchWidgetUtils'; import { getWidgetStatus, parseToolArgs } from './toolWidgetUtils'; /** * Pure logic backing the media/screenshot tool widgets (GeneratedImages and * the browser screenshot card). Ports of the web builder's parsers * (tools-ui/utils.ts `extractImageUrl`, generated-media/useGeneratedImages.ts) * so the native widgets read the same results and statuses. No RN imports — * unit-testable in the node environment. */ // ── Screenshot results (image_url content blocks) ────────────────────────── type ImageUrlItem = { type?: string; image_url?: string | { url?: string }; }; /** * Screenshot-style results are an array of `image_url` content blocks (or the * JSON string thereof) — port of the web tools-ui `extractImageUrl`. */ export function extractImageUrl(results: unknown): string | null { if (!results) return null; let items: unknown = results; if (typeof results === 'string') { try { items = JSON.parse(results); } catch { return null; } } if (!Array.isArray(items)) return null; for (const item of items as ImageUrlItem[]) { if (item?.type === 'image_url') { const imageUrl = item.image_url; if (typeof imageUrl === 'string') return imageUrl; return imageUrl?.url || null; } } return null; } // ── generate_image ────────────────────────────────────────────────────────── /** * Stock image the backend persists into `tool_call.results` for a failed or * cancelled generate_image — a reloaded card must read it as a failure, not a * real image. Keep in sync with FALLBACK_IMAGE_URL in * backend/app/user_apps/builder/chat/image_placeholder_replacement.py. */ export const FALLBACK_IMAGE_URL = 'https://static.wixstatic.com/media/12d367_4f26ccd17f8f4e3a8958306ea08c2332~mv2.png'; /** * Coerce a generated-image result into a plain URL string. `results` can be a * URL string, a `{ url }` object, a JSON-encoded form of either, or an array * of `image_url` blocks — port of the web normalizeImageUrl. */ export function normalizeImageUrl(value: unknown): string | null { if (!value) return null; if (Array.isArray(value)) { const fromBlocks = extractImageUrl(value); if (fromBlocks) return fromBlocks; // Lenient fallback for items without a `type: 'image_url'` tag. for (const item of value) { const resolved = normalizeImageUrl( item && typeof item === 'object' && 'image_url' in item ? (item as { image_url: unknown }).image_url : item, ); if (resolved) return resolved; } return null; } if (typeof value === 'string') { const trimmed = value.trim(); // Only attempt a parse when it looks like JSON — a plain URL passes through. if (trimmed.startsWith('{') || trimmed.startsWith('[')) { const fromBlocks = extractImageUrl(trimmed); if (fromBlocks) return fromBlocks; try { const fromJson = normalizeImageUrl(JSON.parse(trimmed)); if (fromJson) return fromJson; } catch { // Not valid JSON — fall through to the URL scrape. } // Python-style dict reprs (single quotes) aren't JSON — pull the first // http(s) URL straight out of the text. const match = trimmed.match(/https?:\/\/[^\s"'}\])]+/); return match ? match[0] : null; } return value; } if (typeof value === 'object') { const obj = value as Record; if (typeof obj.url === 'string') return obj.url; if ('image_url' in obj) return normalizeImageUrl(obj.image_url); } return null; } /** The `label` arg, tolerating a still-streaming arguments string. */ export function getMediaLabel(toolCall: SuperagentToolCall): string { const args = parseToolArgs(toolCall); if (typeof args?.label === 'string') return args.label; const raw = typeof toolCall.arguments_string === 'string' ? toolCall.arguments_string : ''; const match = raw.match(/"label"\s*:\s*"([^"]*)/); return match?.[1] || ''; } export type GeneratedImageStatus = 'generating' | 'ready' | 'failed'; export type GeneratedImageItem = { id: string; /** Title from the tool args; empty until it streams in. */ label: string; /** Resolved image URL once ready, otherwise null. */ url: string | null; status: GeneratedImageStatus; }; export type GeneratedImagesResult = { items: GeneratedImageItem[]; readyCount: number; total: number; allDone: boolean; }; function getImageGenerationStatus(value: unknown): string | null { if (value && typeof value === 'object' && !Array.isArray(value)) { const status = (value as Record).status; return typeof status === 'string' ? status : null; } if (typeof value !== 'string' || !value.trim().startsWith('{')) return null; try { return getImageGenerationStatus(JSON.parse(value)); } catch { return null; } } /** * Turn a batch of generate_image calls into render-ready items — port of the * web useGeneratedImages, minus its `imagePreviewUrls` socket map: the tool * returns a pending generation-state object instantly. The web swaps it live * via the image_ready socket; natively the completed state arrives with the * next message refresh. Until then the item stays `generating`. */ export function getGeneratedImages(toolCalls: SuperagentToolCall[]): GeneratedImagesResult { const items: GeneratedImageItem[] = toolCalls.map((toolCall, index) => { const rawResult = toolCall.results; const resultUrl = typeof rawResult === 'string' ? rawResult : null; const isPlaceholder = Boolean(resultUrl?.startsWith('/__generating__/')); const generationStatus = getImageGenerationStatus(rawResult); // Guard against non-URL text: a failed call puts an error message in // `results`, which must NOT be rendered as an image source. Accept absolute // (http/https/data) and root-relative URLs — error messages are prose. const resolved = isPlaceholder ? null : normalizeImageUrl(rawResult); const isFallbackImage = resolved === FALLBACK_IMAGE_URL; const url = !isFallbackImage && resolved && /^(https?:|data:|\/)/.test(resolved) ? resolved : null; const toolErrored = toolCall.status === 'error' || toolCall.status === 'failed'; const resultIsErrorText = !isPlaceholder && Boolean(resultUrl?.trim()) && !url; const failed = generationStatus === 'failed' || toolErrored || resultIsErrorText || isFallbackImage; return { id: toolCall.id || `generated-image-${index}`, label: getMediaLabel(toolCall), url, status: failed ? 'failed' : url ? 'ready' : 'generating', }; }); const readyCount = items.filter((item) => item.status === 'ready').length; return { items, readyCount, total: items.length, allDone: items.every((item) => item.status !== 'generating'), }; } /** * Header copy for a generated-images batch — same shapes as the web * useGeneratedImagesHeader: a single image reads plainly, two or more get live * `ready/total` progress, and partial failures are called out explicitly. */ export function getGeneratedImagesHeader({ allDone, readyCount, total }: GeneratedImagesResult): string { if (!allDone) { return total === 1 ? 'Generating image' : `Generating images… ${readyCount}/${total}`; } if (readyCount === 0) return total === 1 ? 'Failed to generate image' : 'Failed to generate images'; const failedCount = total - readyCount; if (failedCount > 0) return `Generated ${pluralize(readyCount, 'image')} · ${failedCount} failed`; return total === 1 ? 'Generated image' : `Generated ${readyCount} images`; } // ── Screenshots (browserbase_screenshot / local_browser_screenshot) ──────── export type ScreenshotState = { phase: 'ready' | 'running' | 'error' | 'taken'; imageUrl: string | null; }; /** * State machine of the web Browserbase screenshot card: once the image_url * result lands the row is 'ready'; a settled call without an image is a plain * 'taken' success row; otherwise the row keeps spinning (status can flip to * success before results land — web parity). */ export function getScreenshotState(toolCall: SuperagentToolCall): ScreenshotState { const imageUrl = extractImageUrl(toolCall.results); if (imageUrl) return { phase: 'ready', imageUrl }; const status = getWidgetStatus(toolCall.status); if (status === 'error') return { phase: 'error', imageUrl: null }; if (status !== 'running' && status !== 'waiting') return { phase: 'taken', imageUrl: null }; return { phase: 'running', imageUrl: null }; }