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:
15
.env.example
Normal file
15
.env.example
Normal 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
31
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
115
src/lib/flowerFlow/api.js
Normal 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}`;
|
||||
}
|
||||
35
src/lib/flowerFlow/session.js
Normal file
35
src/lib/flowerFlow/session.js
Normal 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
169
src/lib/server/aiError.js
Normal 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
|
||||
};
|
||||
}
|
||||
145
src/lib/server/flowerFlow/flowerDB.js
Normal file
145
src/lib/server/flowerFlow/flowerDB.js
Normal 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)
|
||||
};
|
||||
}
|
||||
110
src/lib/server/flowerFlow/jobStore.js
Normal file
110
src/lib/server/flowerFlow/jobStore.js
Normal 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';
|
||||
}
|
||||
}
|
||||
57
src/lib/server/gemini/client.js
Normal file
57
src/lib/server/gemini/client.js
Normal 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');
|
||||
}
|
||||
}
|
||||
85
src/lib/server/gemini/image.js
Normal file
85
src/lib/server/gemini/image.js
Normal 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
|
||||
};
|
||||
}
|
||||
66
src/lib/server/gemini/mock.js
Normal file
66
src/lib/server/gemini/mock.js
Normal 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}.`;
|
||||
}
|
||||
104
src/lib/server/gemini/text.js
Normal file
104
src/lib/server/gemini/text.js
Normal 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();
|
||||
}
|
||||
48
src/lib/server/gemini/vision.js
Normal file
48
src/lib/server/gemini/vision.js
Normal 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
96
src/lib/server/http.js
Normal 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());
|
||||
}
|
||||
54
src/lib/server/openai/image.js
Normal file
54
src/lib/server/openai/image.js
Normal 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');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
88
src/routes/api/flower-flow/generate-images/+server.js
Normal file
88
src/routes/api/flower-flow/generate-images/+server.js
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/routes/api/flower-flow/job/+server.js
Normal file
30
src/routes/api/flower-flow/job/+server.js
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/routes/api/flower-flow/mood-analysis/+server.js
Normal file
31
src/routes/api/flower-flow/mood-analysis/+server.js
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/routes/api/flower-flow/recipe/+server.js
Normal file
40
src/routes/api/flower-flow/recipe/+server.js
Normal 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);
|
||||
}
|
||||
}
|
||||
49
src/routes/api/flower-flow/select-option/+server.js
Normal file
49
src/routes/api/flower-flow/select-option/+server.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
178
src/routes/generating/+page.svelte
Normal file
178
src/routes/generating/+page.svelte
Normal 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>
|
||||
16
src/routes/map/+page.svelte
Normal file
16
src/routes/map/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
]}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user