feat: implement AI bouquet generation flow with Gemini/OpenAI

* feat: scaffold message, generating, and map pages and align header steps

* feat: implement AI bouquet generation flow with Gemini/OpenAI

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chaewon Lee
2026-06-09 17:07:38 +09:00
committed by GitHub
parent d0ba482451
commit d8f93f4c17
33 changed files with 2008 additions and 54 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Gemini
GEMINI_API_KEY=
GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
# Image generation
# IMAGE_PROVIDER: openai | gemini | mock
# mock = instant placeholder images, zero API calls (develop without burning quota)
IMAGE_PROVIDER=openai
OPENAI_API_KEY=
OPENAI_IMAGE_MODEL=gpt-image-1
OPENAI_IMAGE_SIZE=1024x1024
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image
# Kakao REST API (used later for /map)
KAKAO_REST_API_KEY=

31
package-lock.json generated
View File

@@ -7,6 +7,10 @@
"": {
"name": "ai-florist",
"version": "0.0.1",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"openai": "^6.42.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.4",
"@eslint/js": "^10.0.1",
@@ -210,6 +214,15 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2137,6 +2150,24 @@
],
"license": "MIT"
},
"node_modules/openai": {
"version": "6.42.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz",
"integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==",
"license": "Apache-2.0",
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",

View File

@@ -29,5 +29,9 @@
"svelte": "^5.55.2",
"tailwindcss": "^4.2.2",
"vite": "^8.0.7"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"openai": "^6.42.0"
}
}

View File

@@ -1,7 +1,7 @@
<script>
let { children } = $props();
let { children, onclick = undefined } = $props();
</script>
<button class="bg-black px-6 py-3 text-white">
<button class="bg-black px-6 py-3 text-white" {onclick}>
{@render children()}
</button>

View File

@@ -1,6 +1,6 @@
<script>
// `step` is 1-based; the matching dot is highlighted as the current step.
let { step = 1, total = 6 } = $props();
let { step = 1, total = 7 } = $props();
const dots = $derived(Array.from({ length: total }, (_, i) => i));
</script>

View File

@@ -1,23 +1,70 @@
<script>
import UploadTile from './UploadTile.svelte';
// One reference image per category, laid out as an interlocking collage
// (offset seams, varied heights) instead of equal quarters.
let { primaryFile = $bindable(null) } = $props();
let colorFile = $state(null);
let seasonFile = $state(null);
let characterFile = $state(null);
let locationFile = $state(null);
$effect(() => {
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
});
const tiles = [
{ key: 'color', label: 'Color', aspect: 'aspect-4/5' },
{ key: 'season', label: 'Season', aspect: 'aspect-4/3' },
{ key: 'character', label: 'Character', aspect: 'aspect-4/3' },
{ key: 'location', label: 'Location', aspect: 'aspect-4/5' }
{
key: 'color',
label: 'Color',
aspect: 'aspect-4/5',
bindFile: () => colorFile,
setFile: (v) => (colorFile = v)
},
{
key: 'season',
label: 'Season',
aspect: 'aspect-4/3',
bindFile: () => seasonFile,
setFile: (v) => (seasonFile = v)
},
{
key: 'character',
label: 'Character',
aspect: 'aspect-4/3',
bindFile: () => characterFile,
setFile: (v) => (characterFile = v)
},
{
key: 'location',
label: 'Location',
aspect: 'aspect-4/5',
bindFile: () => locationFile,
setFile: (v) => (locationFile = v)
}
];
</script>
<div class="moodboard w-full min-h-0 flex-1">
{#each tiles as tile (tile.key)}
<UploadTile
label={tile.label}
class="tile tile-{tile.key} h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto {tile.aspect}"
/>
{/each}
<div class="moodboard min-h-0 w-full flex-1">
<UploadTile
label="Color"
bind:file={colorFile}
class="tile tile-color aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
label="Season"
bind:file={seasonFile}
class="tile tile-season aspect-4/3 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
label="Character"
bind:file={characterFile}
class="tile tile-character aspect-4/3 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
label="Location"
bind:file={locationFile}
class="tile tile-location aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
</div>
<style>

View File

@@ -1,14 +1,25 @@
<script>
import UploadTile from './UploadTile.svelte';
// Two SNS feed screenshots. On desktop they fill the panel edge-to-edge in
// a staggered composition (one raised on the right, one dropped on the
// left); below that they fall back to a simple side-by-side / stacked grid.
let { primaryFile = $bindable(null) } = $props();
let firstFile = $state(null);
let secondFile = $state(null);
$effect(() => {
primaryFile = firstFile ?? secondFile ?? null;
});
</script>
<div class="feed w-full min-h-0 flex-1">
<UploadTile class="tile-one h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto aspect-4/5" />
<UploadTile class="tile-two h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto aspect-4/5" />
<div class="feed min-h-0 w-full flex-1">
<UploadTile
bind:file={firstFile}
class="tile-one aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
<UploadTile
bind:file={secondFile}
class="tile-two aspect-4/5 h-full min-h-0 w-full max-lg:aspect-auto lg:aspect-auto"
/>
</div>
<style>

View File

@@ -5,15 +5,16 @@
// the chosen image (cover) when filled. Layout (size / grid placement) is
// supplied by the parent via `class` and `style` so the same tile works in
// both the moodboard and the SNS feed.
let { label = null, class: klass = '', style = '' } = $props();
let { label = null, class: klass = '', style = '', file = $bindable(null) } = $props();
let preview = $state(null);
function pick(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
const picked = event.currentTarget.files?.[0];
if (!picked) return;
if (preview) URL.revokeObjectURL(preview);
preview = URL.createObjectURL(file);
file = picked;
preview = URL.createObjectURL(picked);
}
onDestroy(() => {

115
src/lib/flowerFlow/api.js Normal file
View File

@@ -0,0 +1,115 @@
/**
* Error thrown for a non-OK API response, carrying the structured fields the
* server attaches (code/retryable/permanent/retryAfterMs) so callers can decide
* how to react instead of regex-matching the message.
*/
export class GenerationError extends Error {
/**
* @param {string} message
* @param {{ status?: number, code?: string, retryable?: boolean, permanent?: boolean, retryAfterMs?: number }} [info]
*/
constructor(message, info = {}) {
super(message);
this.name = 'GenerationError';
this.status = info.status ?? 0;
this.code = info.code ?? 'unknown';
this.retryable = Boolean(info.retryable);
this.permanent = Boolean(info.permanent);
this.retryAfterMs = typeof info.retryAfterMs === 'number' ? info.retryAfterMs : 0;
}
}
/**
* @param {Response} response
*/
async function parseResponse(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new GenerationError(
typeof data.error === 'string' ? data.error : `Request failed (${response.status})`,
{
status: response.status,
code: typeof data.code === 'string' ? data.code : undefined,
retryable: data.retryable,
permanent: data.permanent,
retryAfterMs: data.retryAfterMs
}
);
}
return data;
}
/**
* @param {File} image
* @param {Record<string, unknown>} userInput
*/
export async function analyzeMood(image, userInput) {
const formData = new FormData();
formData.append('image', image);
for (const [key, value] of Object.entries(userInput)) {
if (value !== undefined && value !== null && value !== '') {
formData.append(key, String(value));
}
}
const response = await fetch('/api/flower-flow/mood-analysis', {
method: 'POST',
body: formData
});
return parseResponse(response);
}
/**
* @param {string} jobId
* @param {Record<string, unknown>} [userInput]
*/
export async function buildRecipe(jobId, userInput) {
const response = await fetch('/api/flower-flow/recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, userInput })
});
return parseResponse(response);
}
/** @param {string} jobId */
export async function generateImages(jobId) {
const response = await fetch('/api/flower-flow/generate-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId })
});
return parseResponse(response);
}
/**
* @param {string} jobId
* @param {'S' | 'M' | 'L'} size
*/
export async function selectOption(jobId, size) {
const response = await fetch('/api/flower-flow/select-option', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, size })
});
return parseResponse(response);
}
/** @param {string} jobId */
export async function fetchJob(jobId) {
const response = await fetch(`/api/flower-flow/job?jobId=${encodeURIComponent(jobId)}`);
return parseResponse(response);
}
/**
* @param {{ mimeType?: string, base64?: string } | null | undefined} image
*/
export function toDataUrl(image) {
if (!image?.base64) return '';
return `data:${image.mimeType || 'image/png'};base64,${image.base64}`;
}

View File

@@ -0,0 +1,35 @@
const STORAGE_KEY = 'flower-flow';
/** @returns {Record<string, unknown>} */
export function loadFlow() {
if (typeof sessionStorage === 'undefined') return {};
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}');
} catch {
return {};
}
}
/** @param {Record<string, unknown>} patch */
export function saveFlow(patch) {
const next = { ...loadFlow(), ...patch };
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
}
export function clearFlow() {
sessionStorage.removeItem(STORAGE_KEY);
}
/** @param {string} key */
export function getFlowString(key) {
const value = loadFlow()[key];
return typeof value === 'string' ? value : '';
}
/** @param {string} key */
export function getFlowObject(key) {
const value = loadFlow()[key];
return value && typeof value === 'object' ? value : null;
}

169
src/lib/server/aiError.js Normal file
View File

@@ -0,0 +1,169 @@
/**
* Classify an AI provider error (Gemini / OpenAI) into a structured shape the
* API layer and client can act on, instead of regex-matching message strings.
*
* The goal: never again show "rate-limited, retrying forever" for an error that
* is actually permanent (billing/quota exhausted, bad auth) or unrelated.
*
* @typedef {Object} AiErrorInfo
* @property {number} status HTTP status to return to the client
* @property {string} message human-readable message
* @property {string} code short machine code (e.g. 'rate_limited')
* @property {boolean} retryable safe to retry after a delay?
* @property {boolean} permanent will never succeed without user action?
* @property {number} retryAfterMs suggested wait before retrying
*/
const DEFAULT_RETRY_MS = 15_000;
const MIN_RETRY_MS = 5_000;
const MAX_RETRY_MS = 60_000;
/** @param {number} seconds */
function clampRetryMs(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_RETRY_MS;
return Math.max(MIN_RETRY_MS, Math.min(seconds * 1000, MAX_RETRY_MS));
}
/**
* Pull a "retry after N seconds" hint out of whatever the provider gave us:
* an OpenAI `retry-after` header, a Gemini RetryInfo detail, or the raw message.
* @param {any} error
* @param {string} message
*/
function extractRetryMs(error, message) {
// OpenAI: retry-after header (object map or Headers instance)
const headers = error?.headers;
if (headers) {
const raw =
typeof headers.get === 'function' ? headers.get('retry-after') : headers['retry-after'];
if (raw) return clampRetryMs(Number(raw));
}
// Gemini legacy SDK: errorDetails[].retryDelay = "56s"
const details = error?.errorDetails;
if (Array.isArray(details)) {
for (const detail of details) {
const delay = detail?.retryDelay;
if (typeof delay === 'string') {
const seconds = Number(delay.replace(/s$/i, ''));
if (Number.isFinite(seconds)) return clampRetryMs(seconds);
}
}
}
// Fallback: scrape the message ("retry in 56s" / "retryDelay": "56s")
const match =
message.match(/retry(?:\s+in|delay)["'\s:]*([\d.]+)\s*s/i) ??
message.match(/"retryDelay"\s*:\s*"?([\d.]+)s/i);
if (match) return clampRetryMs(Number(match[1]));
return DEFAULT_RETRY_MS;
}
/**
* @param {unknown} error
* @returns {AiErrorInfo}
*/
export function describeAiError(error) {
const message = error instanceof Error ? error.message : String(error);
/** @type {any} */
const anyErr = error;
// Numeric status from either SDK (OpenAI APIError.status, Gemini fetch error .status)
let status = typeof anyErr?.status === 'number' ? anyErr.status : 0;
if (!status) {
const m = message.match(/\[(\d{3})\b/) ?? message.match(/\b(4\d{2}|5\d{2})\b/);
if (m) status = Number(m[1]);
}
const code = typeof anyErr?.code === 'string' ? anyErr.code : '';
const lower = `${code} ${message}`.toLowerCase();
// Permanent billing/quota: waiting will NEVER help — the user must act.
const billingExhausted =
code === 'insufficient_quota' ||
/insufficient_quota|exceeded your current quota|billing|payment|plan and billing/.test(lower);
if (billingExhausted) {
return {
status: 402,
code: 'quota_exhausted',
message:
'Image generation is blocked: the provider account is out of quota/credits. Check billing and usage limits on the provider dashboard.',
retryable: false,
permanent: true,
retryAfterMs: 0
};
}
// Auth / verification problems — also permanent until the user fixes config.
if (
status === 401 ||
status === 403 ||
/api key|unauthorized|permission|verify your org/.test(lower)
) {
return {
status: status === 403 ? 403 : 401,
code: 'auth',
message: `Provider rejected the request (auth/permission): ${message}`,
retryable: false,
permanent: true,
retryAfterMs: 0
};
}
// Transient rate limit.
if (
status === 429 ||
/rate limit|too many requests|resource has been exhausted|quota/.test(lower)
) {
return {
status: 429,
code: 'rate_limited',
message: 'AI provider is rate-limiting requests right now.',
retryable: true,
permanent: false,
retryAfterMs: extractRetryMs(anyErr, message)
};
}
// Transient server-side outage / overload.
if (
status === 500 ||
status === 502 ||
status === 503 ||
status === 504 ||
/overloaded|unavailable|high demand|try again later/.test(lower)
) {
return {
status: 503,
code: 'unavailable',
message: 'AI provider is temporarily unavailable or overloaded.',
retryable: true,
permanent: false,
retryAfterMs: extractRetryMs(anyErr, message)
};
}
// Bad request (e.g. unsupported model, bad prompt) — retrying won't help.
if (status === 400 || status === 404 || status === 422) {
return {
status,
code: 'bad_request',
message: `Provider rejected the request: ${message}`,
retryable: false,
permanent: true,
retryAfterMs: 0
};
}
// Unknown: treat as a non-retryable server error so it surfaces instead of looping.
return {
status: 500,
code: 'unknown',
message: message || 'Unexpected error during generation.',
retryable: false,
permanent: false,
retryAfterMs: 0
};
}

View File

@@ -0,0 +1,145 @@
/** @typedef {import('./jobStore.js').MoodAnalysis} MoodAnalysis */
/**
* @typedef {Object} FlowerRecord
* @property {string} name
* @property {string[]} colors
* @property {string[]} season
* @property {string} wordOfFlower
* @property {string[]} meanings
* @property {'low' | 'medium' | 'high'} priceLevel
* @property {string[]} mood
* @property {'main' | 'sub' | 'greenery'} role
*/
/** @type {FlowerRecord[]} */
export const flowerDB = [
{
name: 'Tulip',
colors: ['white', 'pink', 'yellow', 'purple'],
season: ['spring'],
wordOfFlower: 'confession of love',
meanings: ['love', 'passion', 'devotion'],
priceLevel: 'medium',
mood: ['soft', 'romantic', 'clean'],
role: 'main'
},
{
name: 'Gerbera',
colors: ['white', 'pink', 'yellow', 'orange', 'red'],
season: ['spring', 'summer'],
wordOfFlower: 'cheerfulness',
meanings: ['joy', 'friendship', 'warmth'],
priceLevel: 'low',
mood: ['bright', 'playful', 'cheerful'],
role: 'main'
},
{
name: "Baby's breath",
colors: ['white', 'pink'],
season: ['spring', 'summer', 'autumn'],
wordOfFlower: 'pure heart',
meanings: ['innocence', 'everlasting love'],
priceLevel: 'low',
mood: ['airy', 'delicate', 'soft'],
role: 'sub'
},
{
name: 'Rose',
colors: ['white', 'pink', 'red', 'peach'],
season: ['spring', 'summer', 'autumn'],
wordOfFlower: 'love',
meanings: ['romance', 'gratitude', 'elegance'],
priceLevel: 'medium',
mood: ['romantic', 'classic', 'warm'],
role: 'main'
},
{
name: 'Ranunculus',
colors: ['white', 'pink', 'peach', 'yellow'],
season: ['spring'],
wordOfFlower: 'charm',
meanings: ['radiance', 'charm', 'attraction'],
priceLevel: 'medium',
mood: ['soft', 'layered', 'romantic'],
role: 'main'
},
{
name: 'Eucalyptus',
colors: ['green', 'silver green'],
season: ['spring', 'summer', 'autumn', 'winter'],
wordOfFlower: 'protection',
meanings: ['freshness', 'healing'],
priceLevel: 'low',
mood: ['natural', 'minimal', 'clean'],
role: 'greenery'
},
{
name: 'Lisianthus',
colors: ['white', 'pink', 'purple', 'green'],
season: ['summer', 'autumn'],
wordOfFlower: 'gratitude',
meanings: ['appreciation', 'grace'],
priceLevel: 'medium',
mood: ['elegant', 'soft', 'delicate'],
role: 'sub'
},
{
name: 'Daisy',
colors: ['white', 'yellow'],
season: ['spring', 'summer'],
wordOfFlower: 'innocence',
meanings: ['purity', 'new beginnings'],
priceLevel: 'low',
mood: ['fresh', 'natural', 'cheerful'],
role: 'sub'
}
];
/**
* @param {MoodAnalysis} mood
* @param {string} [season]
*/
export function matchFlowersFromMood(mood, season) {
const palette = mood.colorPalette.map((c) => c.toLowerCase());
const keywords = [...mood.moodKeywords, ...mood.styleImpression, ...mood.textureKeywords].map(
(k) => k.toLowerCase()
);
const scoreFlower = (flower) => {
let score = 0;
for (const color of flower.colors) {
if (palette.some((p) => p.includes(color) || color.includes(p))) {
score += 2;
}
}
for (const tag of flower.mood) {
if (keywords.some((k) => k.includes(tag) || tag.includes(k))) {
score += 2;
}
}
if (season && flower.season.includes(season.toLowerCase())) {
score += 1;
}
return score;
};
const ranked = [...flowerDB]
.map((flower) => ({ flower, score: scoreFlower(flower) }))
.sort((a, b) => b.score - a.score);
const mains = ranked.filter(({ flower }) => flower.role === 'main').slice(0, 2);
const subs = ranked.filter(({ flower }) => flower.role === 'sub').slice(0, 2);
const greenery = ranked.filter(({ flower }) => flower.role === 'greenery').slice(0, 1);
return {
mainFlowers: mains.map(({ flower }) => flower.name),
subFlowers: subs.map(({ flower }) => flower.name),
greenery: greenery.map(({ flower }) => flower.name),
colors: mood.colorPalette.slice(0, 3)
};
}

View File

@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto';
/** @typedef {'S' | 'M' | 'L'} BouquetSize */
/**
* @typedef {Object} UserInput
* @property {string} [relationship]
* @property {string} [occasion]
* @property {number} [budget]
* @property {string} [season]
* @property {string} [notes]
*/
/**
* @typedef {Object} MoodAnalysis
* @property {string[]} colorPalette
* @property {string[]} moodKeywords
* @property {string[]} styleImpression
* @property {string[]} textureKeywords
* @property {string} energyLevel
*/
/**
* @typedef {Object} BouquetRecipe
* @property {string} concept
* @property {string[]} mainFlowers
* @property {string[]} subFlowers
* @property {string[]} greenery
* @property {string[]} colors
* @property {string} wrapping
* @property {string} shape
* @property {string} budget
*/
/**
* @typedef {Object} GeneratedImage
* @property {string} mimeType
* @property {string} base64
*/
/**
* @typedef {Object} FlowerJob
* @property {string} id
* @property {number} createdAt
* @property {UserInput} userInput
* @property {MoodAnalysis | null} moodAnalysis
* @property {BouquetRecipe | null} recipe
* @property {string | null} imagePrompt
* @property {Partial<Record<BouquetSize, GeneratedImage>>} images
* @property {BouquetSize | null} selectedSize
* @property {string | null} floristNote
*/
/** @type {Map<string, FlowerJob>} */
const jobs = new Map();
/** @param {Partial<UserInput>} [userInput] */
export function createJob(userInput = {}) {
const id = randomUUID();
const job = {
id,
createdAt: Date.now(),
userInput,
moodAnalysis: null,
recipe: null,
imagePrompt: null,
images: {},
selectedSize: null,
floristNote: null
};
jobs.set(id, job);
return job;
}
/** @param {string} jobId */
export function getJob(jobId) {
return jobs.get(jobId) ?? null;
}
/**
* @param {string} jobId
* @param {Partial<FlowerJob>} patch
*/
export function updateJob(jobId, patch) {
const job = jobs.get(jobId);
if (!job) return null;
const updated = { ...job, ...patch };
jobs.set(jobId, updated);
return updated;
}
/** @param {string} jobId */
export function requireJob(jobId) {
const job = getJob(jobId);
if (!job) {
throw new JobNotFoundError(jobId);
}
return job;
}
export class JobNotFoundError extends Error {
/** @param {string} jobId */
constructor(jobId) {
super(`Job not found: ${jobId}`);
this.name = 'JobNotFoundError';
}
}

View File

@@ -0,0 +1,57 @@
import { env } from '$env/dynamic/private';
import { GoogleGenerativeAI } from '@google/generative-ai';
let client = null;
export function isGeminiConfigured() {
return Boolean(env.GEMINI_API_KEY);
}
function getClient() {
if (!isGeminiConfigured()) {
throw new Error('GEMINI_API_KEY is not configured');
}
if (!client) {
client = new GoogleGenerativeAI(env.GEMINI_API_KEY);
}
return client;
}
export function getTextModel() {
return getClient().getGenerativeModel({
model: env.GEMINI_TEXT_MODEL || 'gemini-2.5-flash'
});
}
export function getVisionModel() {
return getClient().getGenerativeModel({
model: env.GEMINI_TEXT_MODEL || 'gemini-2.5-flash'
});
}
export function getImageModel() {
return getClient().getGenerativeModel({
model: env.GEMINI_IMAGE_MODEL || 'gemini-3.1-flash-image'
});
}
/**
* @param {string} text
*/
export function parseJsonFromText(text) {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const candidate = fenced?.[1]?.trim() ?? text.trim();
try {
return JSON.parse(candidate);
} catch {
const start = candidate.indexOf('{');
const end = candidate.lastIndexOf('}');
if (start >= 0 && end > start) {
return JSON.parse(candidate.slice(start, end + 1));
}
throw new Error('Failed to parse JSON from model response');
}
}

View File

@@ -0,0 +1,85 @@
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
import { env } from '$env/dynamic/private';
import { getImageModel, isGeminiConfigured } from './client.js';
import { mockGeneratedImage } from './mock.js';
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
/** @type {Record<BouquetSize, string>} */
const SIZE_PROMPTS = {
S: 'Create a small version with fewer flowers. Simple, delicate, and affordable.',
M: 'Create a medium version with a balanced amount of flowers and standard florist bouquet volume.',
L: 'Create a large version with more flowers, fuller volume, premium and abundant.'
};
export function getImageProvider() {
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
return configured;
}
return isOpenAIConfigured() ? 'openai' : 'gemini';
}
export function isImageGenerationConfigured() {
const provider = getImageProvider();
if (provider === 'mock') return false;
return provider === 'openai' ? isOpenAIConfigured() : isGeminiConfigured();
}
/**
* @param {string} basePrompt
* @param {BouquetSize} size
* @returns {Promise<GeneratedImage>}
*/
export async function generateBouquetImage(basePrompt, size) {
const prompt = `${basePrompt}\n\n${SIZE_PROMPTS[size]}\nKeep the same flower types, color palette, wrapping style, and mood.`;
const provider = getImageProvider();
// Explicit mock mode: develop the full flow without spending any image quota.
if (provider === 'mock') {
return mockGeneratedImage(size);
}
if (provider === 'openai') {
if (!isOpenAIConfigured()) {
return mockGeneratedImage(size);
}
return generateOpenAIImage(prompt);
}
if (!isGeminiConfigured()) {
return mockGeneratedImage(size);
}
const model = getImageModel();
const result = await model.generateContent(prompt);
const parts = result.response.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.inlineData?.data) {
return {
mimeType: part.inlineData.mimeType || 'image/png',
base64: part.inlineData.data
};
}
}
throw new Error('Gemini image model did not return image data');
}
/**
* @param {string} basePrompt
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
*/
export async function generateAllSizeImages(basePrompt) {
const image = await generateBouquetImage(basePrompt, 'M');
return {
S: image,
M: image,
L: image
};
}

View File

@@ -0,0 +1,66 @@
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
/** @returns {MoodAnalysis} */
export function mockMoodAnalysis() {
return {
colorPalette: ['pale pink', 'ivory', 'light green'],
moodKeywords: ['soft', 'warm', 'natural'],
styleImpression: ['minimal', 'romantic'],
textureKeywords: ['airy', 'delicate'],
energyLevel: 'medium'
};
}
/**
* @param {Partial<import('../flowerFlow/jobStore.js').UserInput>} userInput
* @returns {BouquetRecipe}
*/
export function mockRecipe(userInput = {}) {
const budget = userInput.budget
? `around ₩${userInput.budget.toLocaleString('en-US')}`
: 'around ₩50,000';
return {
concept: 'Soft Romantic Tulip Bouquet',
mainFlowers: ['Pink tulip'],
subFlowers: ["Baby's breath", 'Seasonal white flowers'],
greenery: ['Eucalyptus'],
colors: ['pale pink', 'ivory', 'soft green'],
wrapping: 'ivory paper with pale pink ribbon',
shape: 'loose round bouquet',
budget
};
}
/** @param {BouquetRecipe} recipe */
export function mockImagePrompt(recipe) {
return [
'Generate a realistic florist-style bouquet image.',
'Use real flowers only.',
`Use ${recipe.mainFlowers.join(', ')} as the main flower, mixed with ${recipe.subFlowers.join(', ')}, and ${recipe.greenery.join(', ')}.`,
`Use a ${recipe.colors.join(', ')} color palette.`,
`Wrap it with ${recipe.wrapping}.`,
'White background, soft natural lighting, Korean florist style.'
].join(' ');
}
/** @param {BouquetSize} size */
export function mockGeneratedImage(size) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
<rect width="768" height="1024" fill="#f7f3ef"/>
<text x="50%" y="48%" text-anchor="middle" font-size="42" fill="#6b5b53" font-family="Arial">Mock Bouquet ${size}</text>
<text x="50%" y="54%" text-anchor="middle" font-size="22" fill="#9a8d84" font-family="Arial">Set GEMINI_API_KEY for real images</text>
</svg>`;
return {
mimeType: 'image/svg+xml',
base64: Buffer.from(svg).toString('base64')
};
}
/** @param {BouquetRecipe} recipe */
export function mockFloristNote(recipe) {
return `A ${recipe.shape} built around ${recipe.mainFlowers.join(' and ')}, softened with ${recipe.subFlowers.join(', ')} and ${recipe.greenery.join(', ')}. The palette stays ${recipe.colors.join(', ')} with ${recipe.wrapping}. Budget target: ${recipe.budget}.`;
}

View File

@@ -0,0 +1,104 @@
/** @typedef {import('../flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
import { matchFlowersFromMood } from '../flowerFlow/flowerDB.js';
import { getTextModel, isGeminiConfigured, parseJsonFromText } from './client.js';
import { mockRecipe } from './mock.js';
/**
* @param {MoodAnalysis} mood
* @param {UserInput} userInput
* @returns {Promise<BouquetRecipe>}
*/
export async function buildBouquetRecipe(mood, userInput = {}) {
const mapped = matchFlowersFromMood(mood, userInput.season);
const budget = userInput.budget
? `around ₩${userInput.budget.toLocaleString('en-US')}`
: 'around ₩50,000';
if (!isGeminiConfigured()) {
return mockRecipe(userInput);
}
const model = getTextModel();
const prompt = `You are a professional florist assistant.
Create a realistic bouquet recipe using ONLY real flowers from this candidate list.
Candidate mapping:
${JSON.stringify(mapped, null, 2)}
Mood analysis:
${JSON.stringify(mood, null, 2)}
User context:
${JSON.stringify(userInput, null, 2)}
Return JSON only:
{
"concept": string,
"mainFlowers": string[],
"subFlowers": string[],
"greenery": string[],
"colors": string[],
"wrapping": string,
"shape": string,
"budget": string
}
Rules:
- Do not invent fantasy flowers.
- Keep the bouquet orderable from a real florist in Korea.
- Budget should be ${budget}.`;
const result = await model.generateContent(prompt);
return /** @type {BouquetRecipe} */ (parseJsonFromText(result.response.text()));
}
/**
* @param {BouquetRecipe} recipe
* @returns {Promise<string>}
*/
export async function buildImagePrompt(recipe) {
if (!isGeminiConfigured()) {
const { mockImagePrompt } = await import('./mock.js');
return mockImagePrompt(recipe);
}
const model = getTextModel();
const prompt = `Write one detailed image generation prompt for a realistic florist bouquet.
Use this recipe:
${JSON.stringify(recipe, null, 2)}
Rules:
- Real flowers only
- No fantasy colors or surreal shapes
- White background, soft natural lighting
- Korean florist style
- Return plain text only, no markdown`;
const result = await model.generateContent(prompt);
return result.response.text().trim();
}
/**
* @param {BouquetRecipe} recipe
* @returns {Promise<string>}
*/
export async function buildFloristNote(recipe) {
if (!isGeminiConfigured()) {
const { mockFloristNote } = await import('./mock.js');
return mockFloristNote(recipe);
}
const model = getTextModel();
const prompt = `Write a concise florist note for a customer-facing result screen.
Use this bouquet recipe:
${JSON.stringify(recipe, null, 2)}
Tone: warm, professional, specific.
Return plain text only.`;
const result = await model.generateContent(prompt);
return result.response.text().trim();
}

View File

@@ -0,0 +1,48 @@
/** @typedef {import('../flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
/** @typedef {import('../flowerFlow/jobStore.js').UserInput} UserInput */
import { getVisionModel, isGeminiConfigured, parseJsonFromText } from './client.js';
import { mockMoodAnalysis } from './mock.js';
/**
* @param {Uint8Array} imageBytes
* @param {string} mimeType
* @param {UserInput} userInput
* @returns {Promise<MoodAnalysis>}
*/
export async function analyzeImageMood(imageBytes, mimeType, userInput = {}) {
if (!isGeminiConfigured()) {
return mockMoodAnalysis();
}
const model = getVisionModel();
const prompt = `Analyze this image for bouquet design inspiration.
Return JSON only with this shape:
{
"colorPalette": string[],
"moodKeywords": string[],
"styleImpression": string[],
"textureKeywords": string[],
"energyLevel": "low" | "medium" | "high"
}
User context:
- relationship: ${userInput.relationship ?? 'unknown'}
- occasion: ${userInput.occasion ?? 'unknown'}
- budget: ${userInput.budget ?? 'unknown'}
- season: ${userInput.season ?? 'unknown'}
- notes: ${userInput.notes ?? 'none'}`;
const result = await model.generateContent([
{ text: prompt },
{
inlineData: {
data: Buffer.from(imageBytes).toString('base64'),
mimeType
}
}
]);
const text = result.response.text();
return /** @type {MoodAnalysis} */ (parseJsonFromText(text));
}

96
src/lib/server/http.js Normal file
View File

@@ -0,0 +1,96 @@
import { JobNotFoundError } from '$lib/server/flowerFlow/jobStore.js';
import { describeAiError } from '$lib/server/aiError.js';
/**
* @param {unknown} error
*/
export function toErrorResponse(error) {
if (error instanceof JobNotFoundError) {
console.warn(
`[flower-flow] job_not_found (404) — ${error.message} (server restart wipes jobs)`
);
return new Response(JSON.stringify({ error: error.message, code: 'job_not_found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
if (error instanceof Response) {
return error;
}
const info = describeAiError(error);
// Always log the *real* provider error server-side so it is not hidden behind
// a generic client message. Include the original message for debugging.
console.error(
`[flower-flow] ${info.code} (${info.status})`,
error instanceof Error ? error.stack || error.message : error
);
/** @type {Record<string, string>} */
const headers = { 'Content-Type': 'application/json' };
if (info.retryAfterMs > 0) headers['Retry-After'] = String(Math.ceil(info.retryAfterMs / 1000));
return new Response(
JSON.stringify({
error: info.message,
code: info.code,
retryable: info.retryable,
permanent: info.permanent,
retryAfterMs: info.retryAfterMs
}),
{ status: info.status, headers }
);
}
/**
* @param {unknown} body
* @param {number} [status]
*/
export function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
/**
* @param {FormData} formData
* @param {string} field
*/
export function readOptionalString(formData, field) {
const value = formData.get(field);
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
/**
* @param {FormData} formData
* @param {string} field
*/
export function readOptionalNumber(formData, field) {
const value = readOptionalString(formData, field);
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
/**
* @param {FormData} formData
*/
export function readUserInput(formData) {
return {
relationship: readOptionalString(formData, 'relationship'),
occasion: readOptionalString(formData, 'occasion'),
budget: readOptionalNumber(formData, 'budget'),
season: readOptionalString(formData, 'season'),
notes: readOptionalString(formData, 'notes')
};
}
/**
* @param {Request} request
*/
export async function readJsonBody(request) {
return /** @type {Record<string, unknown>} */ (await request.json());
}

View File

@@ -0,0 +1,54 @@
import { env } from '$env/dynamic/private';
import OpenAI from 'openai';
let client = null;
export function isOpenAIConfigured() {
return Boolean(env.OPENAI_API_KEY);
}
function getOpenAIClient() {
if (!isOpenAIConfigured()) {
throw new Error('OPENAI_API_KEY is not configured');
}
if (!client) {
client = new OpenAI({ apiKey: env.OPENAI_API_KEY });
}
return client;
}
/**
* @param {string} prompt
* @returns {Promise<import('../flowerFlow/jobStore.js').GeneratedImage>}
*/
export async function generateOpenAIImage(prompt) {
const response = await getOpenAIClient().images.generate({
model: env.OPENAI_IMAGE_MODEL || 'gpt-image-1',
prompt,
size: env.OPENAI_IMAGE_SIZE || '1024x1024',
n: 1
});
const image = response.data?.[0];
if (image?.b64_json) {
return {
mimeType: 'image/png',
base64: image.b64_json
};
}
if (image?.url) {
const imageResponse = await fetch(image.url);
const bytes = new Uint8Array(await imageResponse.arrayBuffer());
return {
mimeType: imageResponse.headers.get('content-type') || 'image/png',
base64: Buffer.from(bytes).toString('base64')
};
}
throw new Error('OpenAI image model did not return image data');
}

View File

@@ -1,5 +1,9 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Button from '$lib/components/ui/Button.svelte';
</script>
<Button>start creating</Button>
<main class="flex min-h-dvh items-center justify-center bg-surface px-6">
<Button onclick={() => goto(resolve('/create'))}>start creating</Button>
</main>

View File

@@ -0,0 +1,88 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { buildImagePrompt } from '$lib/server/gemini/text.js';
import {
generateAllSizeImages,
getImageProvider,
isImageGenerationConfigured
} from '$lib/server/gemini/image.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/**
* @param {import('$lib/server/flowerFlow/jobStore.js').GeneratedImage | undefined} image
*/
function isMockImage(image) {
return image?.mimeType === 'image/svg+xml';
}
/**
* Dedupe concurrent generation for the same job. Without this, a remount or
* double-navigation can fire several generate-images requests at once, which is
* a common way to *cause* the very rate limits this page then keeps retrying.
* @type {Map<string, Promise<{ imagePrompt: string, images: Partial<Record<import('$lib/server/flowerFlow/jobStore.js').BouquetSize, import('$lib/server/flowerFlow/jobStore.js').GeneratedImage>> }>>}
*/
const inFlight = new Map();
/** @param {string} jobId @param {import('$lib/server/flowerFlow/jobStore.js').BouquetRecipe} recipe */
function generateForJob(jobId, recipe) {
const existing = inFlight.get(jobId);
if (existing) return existing;
const task = (async () => {
const imagePrompt = await buildImagePrompt(recipe);
const images = await generateAllSizeImages(imagePrompt);
updateJob(jobId, { imagePrompt, images });
return { imagePrompt, images };
})().finally(() => {
inFlight.delete(jobId);
});
inFlight.set(jobId, task);
return task;
}
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
try {
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
if (!jobId) {
return json({ error: 'jobId is required', code: 'bad_request' }, 400);
}
const job = requireJob(jobId);
if (!job.recipe) {
return json({ error: 'recipe is missing. Run recipe first.', code: 'bad_request' }, 400);
}
if (job.images?.M && !isMockImage(job.images.M)) {
console.log(
`[flower-flow] generate-images job=${jobId.slice(0, 8)} cached (already generated)`
);
return json({
jobId,
imagePrompt: job.imagePrompt,
images: job.images,
mock: !isImageGenerationConfigured()
});
}
console.log(
`[flower-flow] generate-images job=${jobId.slice(0, 8)} provider=${getImageProvider()} → generating...`
);
const { imagePrompt, images } = await generateForJob(jobId, job.recipe);
console.log(
`[flower-flow] generate-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
);
return json({
jobId,
imagePrompt,
images,
mock: !isImageGenerationConfigured()
});
} catch (error) {
return toErrorResponse(error);
}
}

View File

@@ -0,0 +1,30 @@
import { requireJob } from '$lib/server/flowerFlow/jobStore.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function GET({ url }) {
try {
const jobId = url.searchParams.get('jobId') ?? '';
if (!jobId) {
return json({ error: 'jobId is required' }, 400);
}
const job = requireJob(jobId);
return json({
jobId: job.id,
userInput: job.userInput,
moodAnalysis: job.moodAnalysis,
recipe: job.recipe,
imagePrompt: job.imagePrompt,
images: job.images,
selectedSize: job.selectedSize,
floristNote: job.floristNote,
mock: !isGeminiConfigured()
});
} catch (error) {
return toErrorResponse(error);
}
}

View File

@@ -0,0 +1,31 @@
import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { analyzeImageMood } from '$lib/server/gemini/vision.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readUserInput, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
try {
const formData = await request.formData();
const image = formData.get('image');
if (!(image instanceof File)) {
return json({ error: 'image file is required' }, 400);
}
const userInput = readUserInput(formData);
const job = createJob(userInput);
const imageBytes = new Uint8Array(await image.arrayBuffer());
const moodAnalysis = await analyzeImageMood(imageBytes, image.type || 'image/jpeg', userInput);
updateJob(job.id, { moodAnalysis });
return json({
jobId: job.id,
moodAnalysis,
mock: !isGeminiConfigured()
});
} catch (error) {
return toErrorResponse(error);
}
}

View File

@@ -0,0 +1,40 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { buildBouquetRecipe } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
try {
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
if (!jobId) {
return json({ error: 'jobId is required' }, 400);
}
const job = requireJob(jobId);
if (!job.moodAnalysis) {
return json({ error: 'moodAnalysis is missing. Run mood-analysis first.' }, 400);
}
if (body.userInput && typeof body.userInput === 'object') {
updateJob(jobId, {
userInput: { ...job.userInput, .../** @type {Record<string, unknown>} */ (body.userInput) }
});
}
const currentJob = requireJob(jobId);
const recipe = await buildBouquetRecipe(currentJob.moodAnalysis, currentJob.userInput);
updateJob(jobId, { recipe });
return json({
jobId,
recipe,
mock: !isGeminiConfigured()
});
} catch (error) {
return toErrorResponse(error);
}
}

View File

@@ -0,0 +1,49 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { buildFloristNote } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/** @type {import('$lib/server/flowerFlow/jobStore.js').BouquetSize[]} */
const VALID_SIZES = ['S', 'M', 'L'];
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
try {
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
const size = typeof body.size === 'string' ? body.size : '';
if (!jobId) {
return json({ error: 'jobId is required' }, 400);
}
if (!VALID_SIZES.includes(size)) {
return json({ error: 'size must be one of S, M, or L' }, 400);
}
const job = requireJob(jobId);
const selectedImage = job.images?.[/** @type {'S'|'M'|'L'} */ (size)];
if (!selectedImage) {
return json({ error: 'selected size image is missing. Run generate-images first.' }, 400);
}
const floristNote = job.recipe ? await buildFloristNote(job.recipe) : null;
updateJob(jobId, {
selectedSize: /** @type {'S'|'M'|'L'} */ (size),
floristNote
});
return json({
jobId,
selectedSize: size,
selectedImage,
floristNote,
recipe: job.recipe,
mock: !isGeminiConfigured()
});
} catch (error) {
return toErrorResponse(error);
}
}

View File

@@ -1,12 +1,17 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import ContextForm from '$lib/components/ui/create/ContextForm.svelte';
import { loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
let who = $state(null);
let whatFor = $state(null);
let style = $state(null);
let budget = $state(50_000);
const savedInput = loadFlow().userInput ?? {};
let who = $state(savedInput.relationship ?? null);
let whatFor = $state(savedInput.occasion ?? null);
let style = $state(savedInput.style ?? null);
let budget = $state(savedInput.budget ?? 50_000);
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
@@ -21,6 +26,18 @@
? `${style ?? '—'} style · ₩${budget.toLocaleString('ko-KR')} budget`
: 'Description Description Description'
);
function handleContinue() {
saveFlow({
userInput: {
relationship: who ?? undefined,
occasion: whatFor ?? undefined,
style: style ?? undefined,
budget: Number(budget)
}
});
goto(resolve('/upload'));
}
</script>
<!--
@@ -30,13 +47,25 @@
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={1} total={6} />
<Header step={1} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<ContextForm bind:who bind:whatFor bind:style bind:budget />
<div
class="fixed right-0 bottom-0 left-0 z-20 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0"
>
<button
type="button"
onclick={handleContinue}
class="w-full bg-pill px-4 py-3 text-sm text-surface"
>
Continue to upload
</button>
</div>
</section>
</main>
</div>

View File

@@ -0,0 +1,178 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import { buildRecipe, generateImages } from '$lib/flowerFlow/api.js';
import { clearFlow, getFlowObject, loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
const MAX_RETRIES = 5;
let status = $state('Preparing bouquet recipe...');
let error = $state('');
let canRetry = $state(false);
let active = true;
/** @param {number} ms */
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Read the structured fields the server now sends. Falls back to message
* sniffing only if an older/unstructured error slips through.
* @param {any} err
*/
function classify(err) {
if (err && (typeof err.retryable === 'boolean' || typeof err.permanent === 'boolean')) {
return {
retryable: Boolean(err.retryable),
permanent: Boolean(err.permanent),
retryAfterMs:
typeof err.retryAfterMs === 'number' && err.retryAfterMs > 0 ? err.retryAfterMs : 15_000
};
}
const message = err instanceof Error ? err.message : String(err);
const retryable =
/rate limit|too many requests|overloaded|unavailable|high demand|quota|exhausted/i.test(
message
);
return { retryable, permanent: false, retryAfterMs: 15_000 };
}
/**
* Run a task with a finite, classified retry policy: permanent errors stop
* immediately, transient ones retry up to MAX_RETRIES respecting the
* server-provided delay, and the real error is surfaced either way.
* @template T
* @param {string} label
* @param {() => Promise<T>} task
* @returns {Promise<T>}
*/
async function runWithRetry(label, task) {
let attempt = 0;
while (active) {
try {
status =
attempt === 0 ? label : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})...`;
error = '';
return await task();
} catch (err) {
const { retryable, permanent, retryAfterMs } = classify(err);
if (permanent || !retryable || attempt >= MAX_RETRIES) {
throw err;
}
attempt += 1;
const seconds = Math.round(retryAfterMs / 1000);
status = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})...`;
await wait(retryAfterMs);
}
}
throw new Error('Generation was cancelled.');
}
async function runGeneration() {
canRetry = false;
const flow = loadFlow();
const jobId = typeof flow.jobId === 'string' ? flow.jobId : '';
const userInput = getFlowObject('userInput') ?? {};
if (!jobId) {
await goto(resolve('/create'));
return;
}
try {
const existingRecipe = getFlowObject('recipe');
if (!existingRecipe) {
const recipeResult = await runWithRetry('Building bouquet recipe...', () =>
buildRecipe(jobId, userInput)
);
saveFlow({ recipe: recipeResult.recipe });
}
const imageResult = await runWithRetry('Generating bouquet image...', () =>
generateImages(jobId)
);
// Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps
// it at ~5MB and throws "QuotaExceededError: The quota has been exceeded."
// The images already live server-side in the job; the options and result
// pages fetch them by jobId. We only keep lightweight metadata here.
saveFlow({
imagesJobId: jobId,
imagePrompt: imageResult.imagePrompt,
mock: imageResult.mock
});
await goto(resolve('/options'));
} catch (err) {
// The server lost this job (e.g. a dev-server restart wipes the in-memory
// job store). The stored jobId is dead, so retrying is pointless — clear
// the stale flow and send the user back to re-upload.
const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
const stale =
code === 'job_not_found' || (err && typeof err === 'object' && err.status === 404);
if (stale) {
// Keep the user's entered context (relationship/occasion/etc.), drop the
// dead job, and re-upload to mint a fresh one.
const userInput = getFlowObject('userInput');
clearFlow();
if (userInput) saveFlow({ userInput });
error = '';
status = 'This session expired. Starting over...';
await goto(resolve('/upload'));
return;
}
const { permanent } = classify(err);
error = err instanceof Error ? err.message : 'Generation failed';
status = permanent ? 'Generation is blocked.' : 'Still failing after several retries.';
canRetry = true;
}
}
function retry() {
if (!active) return;
runGeneration();
}
onMount(() => {
active = true;
runGeneration();
return () => {
active = false;
};
});
</script>
<div class="min-h-dvh bg-surface text-ink">
<Header step={4} total={7} />
<main class="mx-auto flex max-w-xl flex-col items-start px-6 py-16">
<h1 class="mb-3 text-2xl">Generating</h1>
<p class="text-sm text-muted">{status}</p>
{#if error}
<p class="mt-6 text-sm text-red-600">{error}</p>
<div class="mt-4 flex gap-3">
{#if canRetry}
<button type="button" class="bg-pill px-4 py-2 text-sm text-surface" onclick={retry}>
Try again
</button>
{/if}
<button
type="button"
class="border border-pill px-4 py-2 text-sm text-ink"
onclick={() => goto(resolve('/message'))}
>
Back to message
</button>
</div>
{/if}
</main>
</div>

View File

@@ -0,0 +1,16 @@
<script>
import Header from '$lib/components/ui/Header.svelte';
import { getFlowString } from '$lib/flowerFlow/session.js';
const jobId = getFlowString('jobId');
</script>
<div class="min-h-dvh bg-surface text-ink">
<Header step={7} total={7} />
<main class="mx-auto max-w-xl px-6 py-10">
<h1 class="mb-2 text-2xl">Map</h1>
<p class="mb-4 text-sm text-muted">Nearby flower shops will appear here in the next step.</p>
<p class="text-sm text-muted">Current job: {jobId || 'none'}</p>
</main>
</div>

View File

@@ -1,13 +1,40 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import MessageForm from '$lib/components/ui/message/MessageForm.svelte';
import { getFlowObject, loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
let message = $state('');
const flow = loadFlow();
const userInput = getFlowObject('userInput') ?? {};
let message = $state(typeof flow.cardMessage === 'string' ? flow.cardMessage : '');
let error = $state('');
const artworkTitle = $derived(message ? 'Your message' : 'Title');
const artworkDescription = $derived(message || 'Description Description Description');
function handleContinue() {
const current = loadFlow();
if (!current.jobId) {
error = 'Please upload an image first.';
goto(resolve('/upload'));
return;
}
const mergedNotes = [userInput.notes, message ? `Card message: ${message}` : '']
.filter(Boolean)
.join('\n\n');
saveFlow({
cardMessage: message,
userInput: { ...userInput, notes: mergedNotes || undefined }
});
goto(resolve('/generating'));
}
</script>
<!--
@@ -17,13 +44,30 @@
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={4} total={6} />
<Header step={3} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<MessageForm bind:message />
<div
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0"
>
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
{/if}
<button
type="button"
onclick={handleContinue}
class="w-full bg-pill px-4 py-3 text-sm text-surface"
>
Continue to generating
</button>
</div>
</section>
</main>
</div>

View File

@@ -1 +1,107 @@
<h1>/options page</h1>
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import { fetchJob, selectOption, toDataUrl } from '$lib/flowerFlow/api.js';
import { getFlowString } from '$lib/flowerFlow/session.js';
const jobId = getFlowString('jobId');
// Images are large base64 blobs that don't fit in sessionStorage, so they live
// server-side. Fetch them by jobId rather than reading them from the flow store.
let images = $state({});
let loading = $state(true);
let loadingSize = $state(null);
let error = $state('');
const options = [
{ size: 'S', label: 'Small', description: 'Simple, delicate, affordable' },
{ size: 'M', label: 'Medium', description: 'Balanced standard bouquet volume' },
{ size: 'L', label: 'Large', description: 'Fuller, premium and abundant' }
];
onMount(async () => {
if (!jobId) {
await goto(resolve('/create'));
return;
}
try {
const job = await fetchJob(jobId);
if (!job.images?.M) {
// Not generated yet — (re)run generation.
await goto(resolve('/generating'));
return;
}
images = job.images;
loading = false;
} catch {
// Job missing on the server (e.g. a dev-server restart wiped it) — restart.
await goto(resolve('/generating'));
}
});
async function choose(size) {
if (!jobId) {
await goto(resolve('/create'));
return;
}
loadingSize = size;
error = '';
try {
await selectOption(jobId, /** @type {'S'|'M'|'L'} */ (size));
await goto(resolve('/result'));
} catch (err) {
error = err instanceof Error ? err.message : 'Selection failed';
loadingSize = null;
}
}
</script>
<div class="min-h-dvh bg-surface text-ink">
<Header step={5} total={7} />
<main class="mx-auto max-w-5xl px-6 py-10">
<h1 class="mb-2 text-2xl">Choose your bouquet size</h1>
<p class="mb-8 text-sm text-muted">Pick one of the generated options.</p>
{#if error}
<p class="mb-4 text-sm text-red-600">{error}</p>
{/if}
{#if loading}
<p class="text-sm text-muted">Loading options...</p>
{/if}
<div class="grid gap-6 md:grid-cols-3" class:hidden={loading}>
{#each options as option (option.size)}
<button
type="button"
disabled={Boolean(loadingSize)}
onclick={() => choose(option.size)}
class="border border-line bg-track p-4 text-left transition hover:border-line-strong disabled:opacity-50"
>
<div class="mb-4 aspect-[3/4] overflow-hidden bg-surface">
{#if images[option.size]}
<img
src={toDataUrl(images[option.size])}
alt="{option.label} bouquet option"
class="h-full w-full object-cover"
/>
{:else}
<div class="flex h-full items-center justify-center text-sm text-muted">No image</div>
{/if}
</div>
<h2 class="text-lg">{option.label}</h2>
<p class="mt-1 text-sm text-muted">{option.description}</p>
<p class="mt-4 text-sm">
{loadingSize === option.size ? 'Selecting...' : 'Select this option'}
</p>
</button>
{/each}
</div>
</main>
</div>

View File

@@ -1 +1,102 @@
<h1>/result page</h1>
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { getFlowString } from '$lib/flowerFlow/session.js';
let loading = $state(true);
let error = $state('');
let selectedImage = $state(null);
let floristNote = $state('');
let recipe = $state(null);
let selectedSize = $state('');
let mock = $state(false);
onMount(async () => {
const jobId = getFlowString('jobId');
if (!jobId) {
await goto(resolve('/create'));
return;
}
try {
const job = await fetchJob(jobId);
selectedImage = job.selectedSize ? job.images?.[job.selectedSize] : null;
floristNote = job.floristNote ?? '';
recipe = job.recipe ?? null;
selectedSize = job.selectedSize ?? '';
mock = Boolean(job.mock);
loading = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load result';
loading = false;
}
});
</script>
<div class="min-h-dvh bg-surface text-ink">
<Header step={6} total={7} />
<main class="mx-auto max-w-5xl px-6 py-10">
<h1 class="mb-2 text-2xl">Result</h1>
<p class="mb-8 text-sm text-muted">Your selected bouquet and florist note.</p>
{#if loading}
<p class="text-sm text-muted">Loading result...</p>
{:else if error}
<p class="text-sm text-red-600">{error}</p>
{:else}
{#if mock}
<p class="mb-4 text-sm text-muted">Running in mock mode (no Gemini API key).</p>
{/if}
<div class="grid gap-8 lg:grid-cols-2">
<div class="aspect-[3/4] overflow-hidden bg-track">
{#if selectedImage}
<img
src={toDataUrl(selectedImage)}
alt="Selected bouquet"
class="h-full w-full object-cover"
/>
{/if}
</div>
<div class="space-y-6">
<div>
<h2 class="mb-2 text-lg">Selected size</h2>
<p class="text-sm text-muted">{selectedSize || 'Not selected'}</p>
</div>
<div>
<h2 class="mb-2 text-lg">Florist note</h2>
<p class="text-sm leading-relaxed text-muted">{floristNote}</p>
</div>
{#if recipe}
<div>
<h2 class="mb-2 text-lg">Recipe</h2>
<ul class="space-y-1 text-sm text-muted">
<li><strong>Concept:</strong> {recipe.concept}</li>
<li><strong>Main:</strong> {recipe.mainFlowers?.join(', ')}</li>
<li><strong>Sub:</strong> {recipe.subFlowers?.join(', ')}</li>
<li><strong>Greenery:</strong> {recipe.greenery?.join(', ')}</li>
<li><strong>Wrapping:</strong> {recipe.wrapping}</li>
</ul>
</div>
{/if}
<button
type="button"
class="bg-pill px-4 py-2 text-sm text-surface"
onclick={() => goto(resolve('/map'))}
>
Continue to map
</button>
</div>
</div>
{/if}
</main>
</div>

View File

@@ -1,48 +1,93 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte';
import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte';
import { analyzeMood } from '$lib/flowerFlow/api.js';
import { getFlowObject, saveFlow } from '$lib/flowerFlow/session.js';
// "Build Moodboard" is selected by default in the design
let mode = $state('moodboard');
let primaryFile = $state(null);
let loading = $state(false);
let error = $state('');
const userInput = getFlowObject('userInput') ?? {};
async function continueToMessage() {
error = '';
if (!primaryFile) {
error = 'Upload at least one image to continue.';
return;
}
loading = true;
try {
const result = await analyzeMood(primaryFile, userInput);
saveFlow({
jobId: result.jobId,
moodAnalysis: result.moodAnalysis,
recipe: null,
imagePrompt: null,
images: null,
imagesJobId: null,
selectedSize: null,
floristNote: null,
mock: result.mock
});
await goto(resolve('/message'));
} catch (err) {
error = err instanceof Error ? err.message : 'Upload failed';
} finally {
loading = false;
}
}
</script>
<!--
On desktop the split layout is locked to the viewport height so the left
artwork stays put while switching modes. The right panel is a full-bleed
upload canvas with the mode toggle floating over it.
-->
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={3} total={6} />
<Header step={2} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork />
<!-- Right panel: full-bleed workspace + floating tab switch -->
<section
class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:overflow-hidden lg:pb-0"
>
<section class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:overflow-hidden lg:pb-0">
{#if mode === 'moodboard'}
<MoodboardGrid />
<MoodboardGrid bind:primaryFile />
{:else}
<SnsFeedUpload />
<SnsFeedUpload bind:primaryFile />
{/if}
<!-- full-width on mobile; centered pill on desktop -->
<div
class="fixed right-0 bottom-0 left-0 z-20 px-4 pb-5 lg:absolute lg:right-auto lg:bottom-8 lg:left-1/2 lg:w-auto lg:-translate-x-1/2 lg:px-0 lg:pb-0"
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0 lg:pb-0"
>
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
{/if}
<button
type="button"
disabled={loading}
onclick={continueToMessage}
class="w-full bg-pill px-4 py-3 text-sm text-surface disabled:opacity-50"
>
{loading ? 'Analyzing mood...' : 'Continue to message'}
</button>
<div
class="flex w-full items-center rounded-full bg-surface/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur lg:w-auto"
class="flex w-full items-center rounded-full bg-surface/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur"
>
<button
type="button"
onclick={() => (mode = 'sns')}
class={[
'flex-1 rounded-full px-4 py-2.5 text-center text-sm whitespace-nowrap transition-colors lg:flex-none lg:px-5',
'flex-1 rounded-full px-4 py-2.5 text-center text-sm whitespace-nowrap transition-colors',
mode === 'sns' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
@@ -52,7 +97,7 @@
type="button"
onclick={() => (mode = 'moodboard')}
class={[
'flex-1 rounded-full px-4 py-2.5 text-center text-sm whitespace-nowrap transition-colors lg:flex-none lg:px-5',
'flex-1 rounded-full px-4 py-2.5 text-center text-sm whitespace-nowrap transition-colors',
mode === 'moodboard' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>