diff --git a/src/components/AIGradientDialog.svelte b/src/components/AIGradientDialog.svelte index d7f4e4f..1a5aed8 100644 --- a/src/components/AIGradientDialog.svelte +++ b/src/components/AIGradientDialog.svelte @@ -5,6 +5,7 @@ import { linear_angle, linear_named_angle } from '../store/linear'; import { radial_shape, radial_position, radial_named_position } from '../store/radial'; import { conic_angle, conic_position, conic_named_position } from '../store/conic'; + import { updateStops } from '../utils/stops.ts'; /** @type {HTMLDialogElement | null} */ let dialog = null; @@ -14,20 +15,33 @@ /** @type {any} */ let session = null; let modelAvailable = false; + /** @type {'unknown' | 'unavailable' | 'downloadable' | 'downloading' | 'available'} */ + let availability = 'unknown'; // Zod Schema for gradient data + const colorString = z.string().regex( + /^(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|(?:oklch|oklab|lab|lch|hsl|hsla|hwb|rgb|rgba|color)\s*\()/i, + 'color must be a valid CSS color string (hex or function)' + ) + const stopItemSchema = z.object({ + kind: z.literal('stop').optional(), + color: colorString, + position: z.number().min(0).max(100) + }) + const hintItemSchema = z.object({ + kind: z.literal('hint'), + percentage: z.number().min(0).max(100) + }) const gradientZodSchema = z.object({ gradient_type: z.enum(["linear", "radial", "conic"]), gradient_space: z.enum([ "srgb", "srgb-linear", "lab", "oklab", "xyz", "xyz-d50", "xyz-d65", "hsl", "hwb", "lch", "oklch", "display-p3", "a98-rgb", "prophoto-rgb", "rec2020" ]), - gradient_stops: z.array(z.object({ - color: z.string(), - position: z.number().min(0).max(100) - })).min(2), + // An ordered list mixing color stops and transition hints + gradient_stops: z.array(z.union([stopItemSchema, hintItemSchema])).min(2), // Linear gradient specific - linear_angle: z.number().optional(), + linear_angle: z.number().min(0).max(360).optional(), // Radial gradient specific radial_shape: z.enum(["circle", "ellipse"]).optional(), radial_position: z.object({ @@ -35,7 +49,7 @@ y: z.number().min(0).max(100).optional() }).optional(), // Conic gradient specific - conic_angle: z.number().optional(), + conic_angle: z.number().min(0).max(360).optional(), conic_position: z.object({ x: z.number().min(0).max(100).optional(), y: z.number().min(0).max(100).optional() @@ -51,40 +65,13 @@ if ('LanguageModel' in window) { try { // @ts-ignore - LanguageModel is a new Chrome API - const availability = await window.LanguageModel.availability(); - console.log('AI Model availability:', availability); - - if (availability !== 'unavailable') { - modelAvailable = true; - - // If model needs to be downloaded, we can show progress - if (availability === 'downloadable') { - loading = true; - error = 'AI model is being downloaded. This may take a few minutes...'; - - // @ts-ignore - LanguageModel is a new Chrome API - session = await window.LanguageModel.create({ - monitor(m) { - m.addEventListener('downloadprogress', (/** @type {any} */ e) => { - const progress = Math.round(e.loaded * 100); - error = `Downloading AI model: ${progress}%`; - }); - } - }); - - error = ''; - loading = false; - } else if (availability === 'available') { - // Model is ready to use - // @ts-ignore - LanguageModel is a new Chrome API - session = await window.LanguageModel.create(); - } - } else { - error = 'AI model is not available. Please check Chrome settings and hardware requirements.'; - } + const avail = await window.LanguageModel.availability(); + console.log('AI Model availability:', avail); + availability = /** @type {any} */ (avail); + modelAvailable = avail !== 'unavailable'; } catch (err) { - console.error('Error initializing AI:', err); - error = 'Failed to initialize AI model. Make sure you are using Chrome 138+ with the Prompt API enabled.'; + console.error('Error checking AI availability:', err); + error = 'Failed to check AI availability. Make sure you are using Chrome 138+ with the Prompt API enabled.'; } } else { error = 'The Prompt API is not available in your browser. Please use Chrome 138+ and enable the API.'; @@ -105,34 +92,123 @@ } } + async function ensureSession() { + if (session) return; + // @ts-ignore - LanguageModel is a new Chrome API + if (!('LanguageModel' in window)) { + throw new Error('The Prompt API is not available in your browser. Please use Chrome 138+ and enable the API.'); + } + try { + // @ts-ignore - LanguageModel is a new Chrome API + const avail = availability === 'unknown' ? await window.LanguageModel.availability() : availability; + availability = /** @type {any} */ (avail); + if (avail === 'unavailable') { + throw new Error('AI model is not available. Please check Chrome settings and hardware requirements.'); + } + if (avail === 'downloadable' || avail === 'downloading') { + loading = true; + error = 'Preparing AI model...'; + // @ts-ignore - LanguageModel is a new Chrome API + session = await window.LanguageModel.create({ + monitor(m) { + m.addEventListener('downloadprogress', (/** @type {any} */ e) => { + const progress = Math.round(e.loaded * 100); + error = `Downloading AI model: ${progress}%`; + }); + } + }); + error = ''; + loading = false; + } else { + // available + loading = true; + // @ts-ignore - LanguageModel is a new Chrome API + session = await window.LanguageModel.create(); + loading = false; + } + } catch (err) { + console.error('Error preparing AI model:', err); + throw err instanceof Error ? err : new Error(String(err)); + } + } + async function generateGradient() { - if (!userPrompt.trim() || !session) return; + if (!userPrompt.trim()) return; loading = true; error = ''; try { + // Lazily create session on first use (user gesture), including downloads + await ensureSession(); + + // Helpers + const clamp01 = (n) => Math.max(0, Math.min(100, Number(n))); + const clamp360 = (n) => { + const v = Number(n); + if (!Number.isFinite(v)) return 0; + let x = v % 360; + if (x < 0) x += 360; + return x; + }; + function sanitizeJsonLike(text) { + if (typeof text !== 'string') return text; + // Strip code fences and surrounding text, keep the outermost JSON object/array + const start = text.indexOf('{'); + const startArr = text.indexOf('['); + const s = start >= 0 && (startArr < 0 || start < startArr) ? start : startArr; + if (s < 0) return text.trim(); + let end = text.lastIndexOf('}'); + let endArr = text.lastIndexOf(']'); + const e = end >= 0 && (endArr < 0 || end > endArr) ? end : endArr; + if (e < 0) return text.slice(s).trim(); + return text.slice(s, e + 1).trim(); + } + // Create a detailed prompt for the AI const systemPrompt = `You are a CSS gradient generator. Convert the user's description into gradient data. Guidelines: - - For color descriptions like "sunset", "ocean", "forest", use appropriate colors + - For color descriptions like \"sunset\", \"ocean\", \"forest\" use appropriate colors - Default to linear gradients unless the user specifies radial or conic - Use modern color spaces like oklch for vibrant gradients when appropriate - Position stops evenly if not specified - - For linear gradients, interpret directions like "left to right" as angle 90, "top to bottom" as 180 + - Interleave transition hints between adjacent color stops (as {\"kind\":\"hint\",\"percentage\":number}) + - For linear gradients, interpret directions like \"left to right\" as angle 90, \"top to bottom\" as 180 - For radial gradients, default to ellipse shape centered - Return valid CSS color values (hex, rgb, hsl, oklch, etc.) + - Allowed positions and percentages are 0–100 inclusive + - Allowed angles are 0–360 inclusive User request: ${userPrompt}`; - // Use the Prompt API with structured output - const result = await session.prompt(systemPrompt, { - responseConstraint: gradientSchema - }); + let result; + try { + // Try structured output first + result = await session.prompt(systemPrompt, { + responseConstraint: gradientSchema + }); + } catch (err) { + // Fallback: request plain JSON without constraint to avoid UnknownError + console.warn('Structured Prompt failed, retrying without constraint:', err); + const fallbackPrompt = systemPrompt + `\n\nOutput ONLY JSON with this exact shape (no markdown fences, no extra text):\n{\n \"gradient_type\": \"linear|radial|conic\",\n \"gradient_space\": \"oklab|oklch|srgb|display-p3|...\",\n \"gradient_stops\": [\n {\"kind\":\"stop\",\"color\":\"...\",\"position\":number},\n {\"kind\":\"hint\",\"percentage\":number},\n ...\n ],\n \"linear_angle\": number (optional),\n \"radial_shape\": \"circle|ellipse\" (optional),\n \"radial_position\": {\"x\":number,\"y\":number} (optional),\n \"conic_angle\": number (optional),\n \"conic_position\": {\"x\":number,\"y\":number} (optional)\n}`; + result = await session.prompt(fallbackPrompt); + } - // Parse the JSON response - const gradientData = JSON.parse(result); + // Parse the response (could be an object or a string) + let gradientData = null; + if (typeof result === 'string') { + const cleaned = sanitizeJsonLike(result); + try { + gradientData = JSON.parse(cleaned); + } catch (e) { + throw new Error('AI returned non-JSON output. Please try again.'); + } + } else if (result && typeof result === 'object') { + gradientData = result; + } else { + throw new Error('Unexpected AI response format.'); + } // console.log('Generated gradient data:', gradientData); // Update stores directly with the AI-generated gradient data @@ -147,37 +223,69 @@ gradient_space.set(gradientData.gradient_space); } - // Update gradient stops + // Update gradient stops (supports both color stops and transition hints) if (gradientData.gradient_stops && Array.isArray(gradientData.gradient_stops)) { // Import Color for color conversion const Color = (await import('colorjs.io')).default; - // Ensure stops have proper structure and convert colors to OKLCH - const formattedStops = gradientData.gradient_stops.map((/** @type {any} */ stop) => { - let oklchColor; - try { - // Convert any color format to OKLCH - const color = new Color(stop.color); - oklchColor = color.to('oklch').toString(); - } catch (err) { - console.warn('Failed to convert color to OKLCH:', stop.color, err); - // Fallback to a default OKLCH color if conversion fails - oklchColor = 'oklch(70% 0.15 180)'; // Default blue-ish color + + // First pass: normalize items, drop invalid colors, clamp positions + const normalized = []; + for (const raw of gradientData.gradient_stops) { + if (!raw) continue; + if (raw.kind === 'hint') { + // Clamp to [0, 100] + const pct = clamp01(raw.percentage); + normalized.push({ kind: 'hint', percentage: pct }); + } else { + // Treat as a color stop (with or without explicit kind) + try { + const color = new Color(raw.color); + const oklchColor = color.to('oklch').toString(); + const pos = clamp01(raw.position); + normalized.push({ kind: 'stop', color: oklchColor, position1: pos, position2: pos }); + } catch (err) { + console.warn('Dropping invalid color from AI output:', raw.color, err); + } } - - return { - color: oklchColor, - position1: stop.position, - position2: stop.position, - kind: 'stop' - }; - }); - gradient_stops.set(formattedStops); + } + + // Ensure we start and end with a color stop and have at least two stops + const stopsOnly = normalized.filter((i) => i.kind === 'stop'); + if (stopsOnly.length < 2) { + // Fallback to a simple gradient if AI output was unusable + gradient_stops.set([ + { kind: 'stop', color: 'oklch(80% 0.2 20)', position1: 0, position2: 0 }, + { kind: 'hint', percentage: 50 }, + { kind: 'stop', color: 'oklch(60% 0.2 200)', position1: 100, position2: 100 }, + ]); + return; + } + + // Second pass: interleave hints between each adjacent pair of stops if missing + const interleaved = []; + for (let i = 0; i < normalized.length; i++) { + const cur = normalized[i]; + interleaved.push(cur); + if (cur.kind === 'stop') { + const next = normalized[i + 1]; + const hasNextStop = normalized.slice(i + 1).find((x) => x.kind === 'stop'); + const nextIsHint = next?.kind === 'hint'; + // Insert a hint if the next item is not a hint but there is another stop later + if (!nextIsHint && hasNextStop) { + interleaved.push({ kind: 'hint', percentage: null }); + } + } + } + + // Final normalization using existing utility to compute auto positions and default hint percentages + const finalList = updateStops(interleaved); + gradient_stops.set(finalList); } // Handle linear gradient properties if (gradientData.gradient_type === 'linear' && gradientData.linear_angle !== undefined) { - linear_angle.set(String(gradientData.linear_angle)); + linear_angle.set(String(clamp360(gradientData.linear_angle))); linear_named_angle.set('--'); // Set to custom angle indicator } @@ -187,7 +295,9 @@ radial_shape.set(gradientData.radial_shape); } if (gradientData.radial_position) { - radial_position.set(gradientData.radial_position); + const rx = gradientData.radial_position.x; + const ry = gradientData.radial_position.y; + radial_position.set({ x: rx == null ? null : clamp01(rx), y: ry == null ? null : clamp01(ry) }); radial_named_position.set('--'); // Set to custom position indicator } else { // Default to center if no position specified @@ -199,10 +309,12 @@ // Handle conic gradient properties if (gradientData.gradient_type === 'conic') { if (gradientData.conic_angle !== undefined) { - conic_angle.set(String(gradientData.conic_angle)); + conic_angle.set(String(clamp360(gradientData.conic_angle))); } if (gradientData.conic_position) { - conic_position.set(gradientData.conic_position); + const cx = gradientData.conic_position.x; + const cy = gradientData.conic_position.y; + conic_position.set({ x: cx == null ? null : clamp01(cx), y: cy == null ? null : clamp01(cy) }); conic_named_position.set('--'); // Set to custom position indicator } else { // Default to center if no position specified @@ -266,7 +378,7 @@