From bd67ac3517d4de0feee1997d316d058b97dde1bc Mon Sep 17 00:00:00 2001 From: javayhu Date: Tue, 24 Jun 2025 23:00:42 +0800 Subject: [PATCH 01/15] chore: support allow promotion code in price config --- src/config/website.tsx | 1 + src/payment/provider/stripe.ts | 1 + src/payment/types.ts | 5 +++-- src/types/index.d.ts | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/config/website.tsx b/src/config/website.tsx index b4144de..cb07491 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -119,6 +119,7 @@ export const websiteConfig: WebsiteConfig = { priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME!, amount: 19900, currency: 'USD', + allowPromotionCode: true, }, ], isFree: false, diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 92c976f..44e2e66 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -227,6 +227,7 @@ export class StripeProvider implements PaymentProvider { success_url: successUrl ?? '', cancel_url: cancelUrl ?? '', metadata: customMetadata, + allow_promotion_codes: price.allowPromotionCode ?? false, }; // Add customer to checkout session diff --git a/src/payment/types.ts b/src/payment/types.ts index c879ed3..7b5bd00 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -46,16 +46,17 @@ export interface Price { currency: string; // Currency code (e.g., USD) interval?: PlanInterval; // Billing interval for recurring payments trialPeriodDays?: number; // Free trial period in days + allowPromotionCode?: boolean; // Whether to allow promotion code for this price disabled?: boolean; // Whether to disable this price in UI } /** * Price plan definition - * + * * 1. When to set the plan disabled? * When the plan is not available anymore, but you should keep it for existing users * who have already purchased it, otherwise they can not see the plan in the Billing page. - * + * * 2. When to set the price disabled? * When the price is not available anymore, but you should keep it for existing users * who have already purchased it, otherwise they can not see the price in the Billing page. diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 45e1b9e..373a463 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import type { PricePlan } from '@/payment/types'; /** * website config, without translations From bc915a53dcb2943b58179a87931ae5c7dea06504 Mon Sep 17 00:00:00 2001 From: javayhu Date: Tue, 24 Jun 2025 23:44:49 +0800 Subject: [PATCH 02/15] chore: add monitor api route --- src/app/api/ping/route.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/app/api/ping/route.ts diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts new file mode 100644 index 0000000..398d60e --- /dev/null +++ b/src/app/api/ping/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +/** + * It is used to check if the server is running. + * You can use tools like Uptime Kuma to monitor this endpoint. + */ +export async function GET() { + return NextResponse.json({ message: 'pong' }); +} From b3180e617d8afb72a7a4e55b070efe6d3e5f4264 Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 25 Jun 2025 20:28:09 +0800 Subject: [PATCH 03/15] chore: support datafast analytics revenue track --- env.example | 4 ++-- src/actions/create-checkout-session.ts | 14 +++++++++++++- src/config/website.tsx | 1 + src/types/index.d.ts | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/env.example b/env.example index 42165be..6519c16 100644 --- a/env.example +++ b/env.example @@ -119,8 +119,8 @@ NEXT_PUBLIC_SELINE_TOKEN="" # DataFast Analytics (https://datafa.st) # https://mksaas.com/docs/analytics#datafast # ----------------------------------------------------------------------------- -NEXT_PUBLIC_DATAFAST_ANALYTICS_ID="" -NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN="" +NEXT_PUBLIC_DATAFAST_WEBSITE_ID="" +NEXT_PUBLIC_DATAFAST_DOMAIN="" # ----------------------------------------------------------------------------- diff --git a/src/actions/create-checkout-session.ts b/src/actions/create-checkout-session.ts index 7a52ae6..f5b6afb 100644 --- a/src/actions/create-checkout-session.ts +++ b/src/actions/create-checkout-session.ts @@ -1,5 +1,6 @@ 'use server'; +import { websiteConfig } from '@/config/website'; import { findPlanByPlanId } from '@/lib/price-plan'; import { getSession } from '@/lib/server'; import { getUrlWithLocale } from '@/lib/urls/urls'; @@ -8,6 +9,7 @@ import type { CreateCheckoutParams } from '@/payment/types'; import { Routes } from '@/routes'; import { getLocale } from 'next-intl/server'; import { createSafeActionClient } from 'next-safe-action'; +import { cookies } from 'next/headers'; import { z } from 'zod'; // Create a safe action client @@ -67,12 +69,22 @@ export const createCheckoutAction = actionClient } // Add user id to metadata, so we can get it in the webhook event - const customMetadata = { + const customMetadata: Record = { ...metadata, userId: session.user.id, userName: session.user.name, }; + // https://datafa.st/docs/stripe-checkout-api + // if datafast analytics is enabled, add the revenue attribution to the metadata + if (websiteConfig.features.enableDatafastRevenueTrack) { + const cookieStore = await cookies(); + customMetadata.datafast_visitor_id = + cookieStore.get('datafast_visitor_id')?.value ?? ''; + customMetadata.datafast_session_id = + cookieStore.get('datafast_session_id')?.value ?? ''; + } + // Create the checkout session with localized URLs const successUrl = getUrlWithLocale( '/settings/billing?session_id={CHECKOUT_SESSION_ID}', diff --git a/src/config/website.tsx b/src/config/website.tsx index cb07491..d5eed4d 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -37,6 +37,7 @@ export const websiteConfig: WebsiteConfig = { enableUpgradeCard: true, enableAffonsoAffiliate: false, enablePromotekitAffiliate: false, + enableDatafastRevenueTrack: false, }, routes: { defaultLoginRedirect: '/dashboard', diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 373a463..2658d83 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -70,6 +70,7 @@ export interface FeaturesConfig { enableUpgradeCard?: boolean; // Whether to enable the upgrade card in the sidebar enableAffonsoAffiliate?: boolean; // Whether to enable affonso affiliate enablePromotekitAffiliate?: boolean; // Whether to enable promotekit affiliate + enableDatafastRevenueTrack?: boolean; // Whether to enable datafast revenue tracking } /** From 985579b96444d72415a6079e8ca4976d5b6c062b Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 26 Jun 2025 00:41:27 +0800 Subject: [PATCH 04/15] feat: support ai image generator --- package.json | 4 + pnpm-lock.yaml | 287 ++++++++++++++++++ src/ai/image/components/ImageCarousel.tsx | 102 +++++++ src/ai/image/components/ImageDisplay.tsx | 195 ++++++++++++ src/ai/image/components/ImageGenerator.tsx | 120 ++++++++ src/ai/image/components/ImagePlayground.tsx | 138 +++++++++ src/ai/image/components/ModelCardCarousel.tsx | 119 ++++++++ src/ai/image/components/ModelSelect.tsx | 153 ++++++++++ src/ai/image/components/PromptInput.tsx | 116 +++++++ src/ai/image/components/PromptSuggestions.tsx | 43 +++ src/ai/image/components/QualityModeToggle.tsx | 56 ++++ src/ai/image/components/Stopwatch.tsx | 17 ++ src/ai/image/components/spinner.tsx | 5 + src/ai/image/hooks/use-image-generation.ts | 171 +++++++++++ src/ai/image/lib/api-types.ts | 12 + src/ai/image/lib/image-helpers.ts | 53 ++++ src/ai/image/lib/image-types.ts | 24 ++ src/ai/image/lib/logos.tsx | 131 ++++++++ src/ai/image/lib/provider-config.ts | 106 +++++++ src/ai/image/lib/suggestions.ts | 138 +++++++++ .../[locale]/(marketing)/ai/image/page.tsx | 30 +- src/app/api/generate-images/route.ts | 124 ++++++++ 22 files changed, 2118 insertions(+), 26 deletions(-) create mode 100644 src/ai/image/components/ImageCarousel.tsx create mode 100644 src/ai/image/components/ImageDisplay.tsx create mode 100644 src/ai/image/components/ImageGenerator.tsx create mode 100644 src/ai/image/components/ImagePlayground.tsx create mode 100644 src/ai/image/components/ModelCardCarousel.tsx create mode 100644 src/ai/image/components/ModelSelect.tsx create mode 100644 src/ai/image/components/PromptInput.tsx create mode 100644 src/ai/image/components/PromptSuggestions.tsx create mode 100644 src/ai/image/components/QualityModeToggle.tsx create mode 100644 src/ai/image/components/Stopwatch.tsx create mode 100644 src/ai/image/components/spinner.tsx create mode 100644 src/ai/image/hooks/use-image-generation.ts create mode 100644 src/ai/image/lib/api-types.ts create mode 100644 src/ai/image/lib/image-helpers.ts create mode 100644 src/ai/image/lib/image-types.ts create mode 100644 src/ai/image/lib/logos.tsx create mode 100644 src/ai/image/lib/provider-config.ts create mode 100644 src/ai/image/lib/suggestions.ts create mode 100644 src/app/api/generate-images/route.ts diff --git a/package.json b/package.json index eae19b8..c21df2c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,11 @@ "knip": "knip" }, "dependencies": { + "@ai-sdk/fal": "^0.1.12", + "@ai-sdk/fireworks": "^0.2.14", + "@ai-sdk/google-vertex": "^2.2.24", "@ai-sdk/openai": "^1.1.13", + "@ai-sdk/replicate": "^0.2.8", "@base-ui-components/react": "1.0.0-beta.0", "@better-fetch/fetch": "^1.1.18", "@dnd-kit/core": "^6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9348237..d56771e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,21 @@ importers: .: dependencies: + '@ai-sdk/fal': + specifier: ^0.1.12 + version: 0.1.12(zod@3.25.64) + '@ai-sdk/fireworks': + specifier: ^0.2.14 + version: 0.2.14(zod@3.25.64) + '@ai-sdk/google-vertex': + specifier: ^2.2.24 + version: 2.2.24(zod@3.25.64) '@ai-sdk/openai': specifier: ^1.1.13 version: 1.1.13(zod@3.25.64) + '@ai-sdk/replicate': + specifier: ^0.2.8 + version: 0.2.8(zod@3.25.64) '@base-ui-components/react': specifier: 1.0.0-beta.0 version: 1.0.0-beta.0(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -351,6 +363,42 @@ importers: packages: + '@ai-sdk/anthropic@1.2.12': + resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/fal@0.1.12': + resolution: {integrity: sha512-Z0pUUR3qwLTj4HXgGJSes5fwjUbSowsMiKbpYKGl6V51sQeUk2EjZctdN4+a+GBuDNCP6Y32Wi8ejM54OMee+w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/fireworks@0.2.14': + resolution: {integrity: sha512-0xlh95Y+L9ccc7hwrjdFKi4u8dirx24FLc70ySXA53u1zZP6R1W35TBYGaLzFpTVhhBhDTOca0mE+/EjJihcxw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/google-vertex@2.2.24': + resolution: {integrity: sha512-zi1ZN6jQEBRke/WMbZv0YkeqQ3nOs8ihxjVh/8z1tUn+S1xgRaYXf4+r6+Izh2YqVHIMNwjhUYryQRBGq20cgQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/google@1.2.19': + resolution: {integrity: sha512-Xgl6eftIRQ4srUdCzxM112JuewVMij5q4JLcNmHcB68Bxn9dpr3MVUSPlJwmameuiQuISIA8lMB+iRiRbFsaqA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + + '@ai-sdk/openai-compatible@0.2.14': + resolution: {integrity: sha512-icjObfMCHKSIbywijaoLdZ1nSnuRnWgMEMLgwoxPJgxsUHMx0aVORnsLUid4SPtdhHI3X2masrt6iaEQLvOSFw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + '@ai-sdk/openai@1.1.13': resolution: {integrity: sha512-IdChK1pJTW3NQis02PG/hHTG0gZSyQIMOLPt7f7ES56C0xH2yaKOU1Tp2aib7pZzWGwDlzTOW2h5TtAB8+V6CQ==} engines: {node: '>=18'} @@ -366,10 +414,20 @@ packages: zod: optional: true + '@ai-sdk/provider-utils@2.2.8': + resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@ai-sdk/provider@1.0.8': resolution: {integrity: sha512-f9jSYwKMdXvm44Dmab1vUBnfCDSFfI5rOtvV1W9oKB7WYHR5dGvCC6x68Mk3NUfrdmNoMVHGoh6JT9HCVMlMow==} engines: {node: '>=18'} + '@ai-sdk/provider@1.1.3': + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} + engines: {node: '>=18'} + '@ai-sdk/react@1.1.17': resolution: {integrity: sha512-NAuEflFvjw1uh1AOmpyi7rBF4xasWsiWUb86JQ8ScjDGxoGDYEdBnaHOxUpooLna0dGNbSPkvDMnVRhoLKoxPQ==} engines: {node: '>=18'} @@ -382,6 +440,12 @@ packages: zod: optional: true + '@ai-sdk/replicate@0.2.8': + resolution: {integrity: sha512-l9t4+RzbAn8osstkbWs6l++Nava+4LO4dsaddnE0GQM5E0BEIgMTJ14hoyfE02Ep0rJZ0M2HlXGqv5heW47P8A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + '@ai-sdk/ui-utils@1.1.15': resolution: {integrity: sha512-NsV/3CMmjc4m53snzRdtZM6teTQUXIKi8u0Kf7GBruSzaMSuZ4DWaAAlUshhR3p2FpZgtsogW+vYG1/rXsGu+Q==} engines: {node: '>=18'} @@ -3554,6 +3618,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + ai@4.1.45: resolution: {integrity: sha512-nQkxQ2zCD+O/h8zJ+PxmBv9coyMaG1uP9kGJvhNaGAA25hbZRQWL0NbTsSJ/QMOUraXKLa+6fBm3VF1NkJK9Kg==} engines: {node: '>=18'} @@ -3616,6 +3684,9 @@ packages: better-call@0.3.3: resolution: {integrity: sha512-N4lDVm0NGmFfDJ0XMQ4O83Zm/3dPlvIQdxvwvgSLSkjFX5PM4GUYSVAuxNzXN27QZMHDkrJTWUqxBrm4tPC3eA==} + bignumber.js@9.3.0: + resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -3634,6 +3705,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3998,6 +4072,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.113: resolution: {integrity: sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==} @@ -4242,6 +4319,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4277,6 +4362,14 @@ packages: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4284,6 +4377,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4327,6 +4424,10 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -4393,6 +4494,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -4423,6 +4528,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -4436,6 +4544,12 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + knip@5.61.2: resolution: {integrity: sha512-ZBv37zDvZj0/Xwk0e93xSjM3+5bjxgqJ0PH2GlB5tnWV0ktXtmatWLm+dLRUCT/vpO3SdGz2nNAfvVhuItUNcQ==} engines: {node: '>=18.18.0'} @@ -4890,6 +5004,15 @@ packages: sass: optional: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -5535,6 +5658,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5638,6 +5764,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -5664,6 +5794,12 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5739,6 +5875,49 @@ packages: snapshots: + '@ai-sdk/anthropic@1.2.12(zod@3.25.64)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + zod: 3.25.64 + + '@ai-sdk/fal@0.1.12(zod@3.25.64)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + zod: 3.25.64 + + '@ai-sdk/fireworks@0.2.14(zod@3.25.64)': + dependencies: + '@ai-sdk/openai-compatible': 0.2.14(zod@3.25.64) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + zod: 3.25.64 + + '@ai-sdk/google-vertex@2.2.24(zod@3.25.64)': + dependencies: + '@ai-sdk/anthropic': 1.2.12(zod@3.25.64) + '@ai-sdk/google': 1.2.19(zod@3.25.64) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + google-auth-library: 9.15.1 + zod: 3.25.64 + transitivePeerDependencies: + - encoding + - supports-color + + '@ai-sdk/google@1.2.19(zod@3.25.64)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + zod: 3.25.64 + + '@ai-sdk/openai-compatible@0.2.14(zod@3.25.64)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + zod: 3.25.64 + '@ai-sdk/openai@1.1.13(zod@3.25.64)': dependencies: '@ai-sdk/provider': 1.0.8 @@ -5754,10 +5933,21 @@ snapshots: optionalDependencies: zod: 3.25.64 + '@ai-sdk/provider-utils@2.2.8(zod@3.25.64)': + dependencies: + '@ai-sdk/provider': 1.1.3 + nanoid: 3.3.8 + secure-json-parse: 2.7.0 + zod: 3.25.64 + '@ai-sdk/provider@1.0.8': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@1.1.3': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/react@1.1.17(react@19.0.0)(zod@3.25.64)': dependencies: '@ai-sdk/provider-utils': 2.1.9(zod@3.25.64) @@ -5768,6 +5958,12 @@ snapshots: react: 19.0.0 zod: 3.25.64 + '@ai-sdk/replicate@0.2.8(zod@3.25.64)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.64) + zod: 3.25.64 + '@ai-sdk/ui-utils@1.1.15(zod@3.25.64)': dependencies: '@ai-sdk/provider': 1.0.8 @@ -8609,6 +8805,8 @@ snapshots: acorn@8.14.0: {} + agent-base@7.1.3: {} + ai@4.1.45(react@19.0.0)(zod@3.25.64): dependencies: '@ai-sdk/provider': 1.0.8 @@ -8675,6 +8873,8 @@ snapshots: uncrypto: 0.1.3 zod: 3.25.64 + bignumber.js@9.3.0: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -8701,6 +8901,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -8964,6 +9166,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.113: {} embla-carousel-react@8.5.2(react@19.0.0): @@ -9351,6 +9557,26 @@ snapshots: function-bind@1.1.2: {} + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gensync@1.0.0-beta.2: {} get-intrinsic@1.2.7: @@ -9393,10 +9619,32 @@ snapshots: globals@11.12.0: {} + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -9526,6 +9774,13 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} image-size@2.0.2: {} @@ -9576,6 +9831,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-stream@2.0.1: {} + is-unicode-supported@0.1.0: {} isexe@2.0.0: {} @@ -9598,6 +9855,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.0 + json-schema@0.4.0: {} json5@2.2.3: {} @@ -9608,6 +9869,17 @@ snapshots: chalk: 5.4.1 diff-match-patch: 1.0.5 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + knip@5.61.2(@types/node@20.19.0)(typescript@5.8.3): dependencies: '@nodelib/fs.walk': 1.2.8 @@ -10282,6 +10554,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.19: {} normalize-path@3.0.0: {} @@ -11125,6 +11401,8 @@ snapshots: dependencies: is-number: 7.0.0 + tr46@0.0.3: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -11231,6 +11509,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@9.0.1: {} + vary@1.1.2: {} vaul@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): @@ -11275,6 +11555,13 @@ snapshots: dependencies: defaults: 1.0.4 + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/ai/image/components/ImageCarousel.tsx b/src/ai/image/components/ImageCarousel.tsx new file mode 100644 index 0000000..339896a --- /dev/null +++ b/src/ai/image/components/ImageCarousel.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; +import { cn } from '@/lib/utils'; +import { useEffect, useState } from 'react'; +import type { GeneratedImage, ProviderTiming } from '../lib/image-types'; +import type { ProviderKey } from '../lib/provider-config'; +import { ImageDisplay } from './ImageDisplay'; + +interface ImageCarouselProps { + providers: ProviderKey[]; + images: GeneratedImage[]; + timings: Record; + failedProviders: ProviderKey[]; + enabledProviders: Record; + providerToModel: Record; +} + +export function ImageCarousel({ + providers, + images, + timings, + failedProviders, + enabledProviders, + providerToModel, +}: ImageCarouselProps) { + const [currentSlide, setCurrentSlide] = useState(0); + const [api, setApi] = useState(); + + useEffect(() => { + if (!api) return; + + api.on('select', () => { + setCurrentSlide(api.selectedScrollSnap()); + }); + }, [api]); + + return ( +
+ + + {providers.map((provider, i) => { + const imageData = images?.find( + (img) => img.provider === provider + )?.image; + const timing = timings[provider]; + + return ( + + img.provider === provider)?.modelId || + providerToModel[provider] + } + provider={provider} + image={imageData} + timing={timing} + failed={failedProviders.includes(provider)} + enabled={enabledProviders[provider]} + /> +
+ {i + 1} of {providers.length} +
+
+ ); + })} +
+ + + +
+ + {/* Dot Indicators */} +
+
+ {providers.map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/ai/image/components/ImageDisplay.tsx b/src/ai/image/components/ImageDisplay.tsx new file mode 100644 index 0000000..86a7a17 --- /dev/null +++ b/src/ai/image/components/ImageDisplay.tsx @@ -0,0 +1,195 @@ +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { AlertCircle, Download, ImageIcon, Share } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { imageHelpers } from '../lib/image-helpers'; +import type { ProviderTiming } from '../lib/image-types'; +import { Stopwatch } from './Stopwatch'; + +interface ImageDisplayProps { + provider: string; + image: string | null | undefined; + timing?: ProviderTiming; + failed?: boolean; + fallbackIcon?: React.ReactNode; + enabled?: boolean; + modelId: string; +} + +export function ImageDisplay({ + provider, + image, + timing, + failed, + fallbackIcon, + modelId, +}: ImageDisplayProps) { + const [isZoomed, setIsZoomed] = useState(false); + + useEffect(() => { + if (isZoomed) { + window.history.pushState({ zoomed: true }, ''); + } + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isZoomed) { + setIsZoomed(false); + } + }; + + const handlePopState = () => { + if (isZoomed) { + setIsZoomed(false); + } + }; + + if (isZoomed) { + document.addEventListener('keydown', handleEscape); + window.addEventListener('popstate', handlePopState); + } + + return () => { + document.removeEventListener('keydown', handleEscape); + window.removeEventListener('popstate', handlePopState); + }; + }, [isZoomed]); + + const handleImageClick = (e: React.MouseEvent) => { + if (image) { + e.stopPropagation(); + setIsZoomed(true); + } + }; + + const handleActionClick = ( + e: React.MouseEvent, + imageData: string, + provider: string + ) => { + e.stopPropagation(); + imageHelpers.shareOrDownload(imageData, provider).catch((error) => { + console.error('Failed to share/download image:', error); + }); + }; + + return ( + <> +
+ {(image || failed) && ( +
+ + + + + + +

{modelId}

+
+
+
+
+ )} + {image && !failed ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Generated + + {timing?.elapsed && ( +
+ + {(timing.elapsed / 1000).toFixed(1)}s + +
+ )} + + ) : ( +
+ {failed ? ( + fallbackIcon || + ) : image ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Generated + + + ) : timing?.startTime ? ( + <> + {/*
{provider}
*/} + + + ) : ( + + )} +
+ )} +
+ + {isZoomed && + image && + createPortal( +
setIsZoomed(false)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Generated e.stopPropagation()} + /> +
, + document.body + )} + + ); +} diff --git a/src/ai/image/components/ImageGenerator.tsx b/src/ai/image/components/ImageGenerator.tsx new file mode 100644 index 0000000..cd3dfd9 --- /dev/null +++ b/src/ai/image/components/ImageGenerator.tsx @@ -0,0 +1,120 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { AlertCircle, ChevronDown, Settings } from 'lucide-react'; +import type { + GeneratedImage, + ImageError, + ProviderTiming, +} from '../lib/image-types'; +import { + PROVIDER_ORDER, + type ProviderKey, + initializeProviderRecord, +} from '../lib/provider-config'; +import { ImageCarousel } from './ImageCarousel'; +import { ImageDisplay } from './ImageDisplay'; + +interface ImageGeneratorProps { + images: GeneratedImage[]; + errors: ImageError[]; + failedProviders: ProviderKey[]; + timings: Record; + enabledProviders: Record; + toggleView: () => void; +} + +export function ImageGenerator({ + images, + errors, + failedProviders, + timings, + enabledProviders, + toggleView, +}: ImageGeneratorProps) { + return ( +
+ {/* If there are errors, render a collapsible alert */} + {errors.length > 0 && ( + + + + + +
+ {errors.map((err, index) => ( + + +
+ + {err.provider} Error + + + {err.message} + +
+
+ ))} +
+
+
+ )} + +
+

Generated Images

+ +
+ + {/* Mobile layout: Carousel */} +
+ ()} + /> +
+ + {/* Desktop layout: Grid */} +
+ {PROVIDER_ORDER.map((provider) => { + const imageItem = images.find((img) => img.provider === provider); + const imageData = imageItem?.image; + const timing = timings[provider]; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/ai/image/components/ImagePlayground.tsx b/src/ai/image/components/ImagePlayground.tsx new file mode 100644 index 0000000..6e92240 --- /dev/null +++ b/src/ai/image/components/ImagePlayground.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState } from 'react'; +import { useImageGeneration } from '../hooks/use-image-generation'; +import { + MODEL_CONFIGS, + type ModelMode, + PROVIDERS, + PROVIDER_ORDER, + type ProviderKey, + initializeProviderRecord, +} from '../lib/provider-config'; +import type { Suggestion } from '../lib/suggestions'; +import { ModelCardCarousel } from './ModelCardCarousel'; +import { ModelSelect } from './ModelSelect'; +import { PromptInput } from './PromptInput'; + +export function ImagePlayground({ + suggestions, +}: { + suggestions: Suggestion[]; +}) { + const { + images, + timings, + failedProviders, + isLoading, + startGeneration, + activePrompt, + } = useImageGeneration(); + + const [showProviders, setShowProviders] = useState(true); + const [selectedModels, setSelectedModels] = useState< + Record + >(MODEL_CONFIGS.performance); + const [enabledProviders, setEnabledProviders] = useState( + initializeProviderRecord(true) + ); + const [mode, setMode] = useState('performance'); + const toggleView = () => { + setShowProviders((prev) => !prev); + }; + + const handleModeChange = (newMode: ModelMode) => { + setMode(newMode); + setSelectedModels(MODEL_CONFIGS[newMode]); + setShowProviders(true); + }; + + const handleModelChange = (providerKey: ProviderKey, model: string) => { + setSelectedModels((prev) => ({ ...prev, [providerKey]: model })); + }; + + const handleProviderToggle = (provider: string, enabled: boolean) => { + setEnabledProviders((prev) => ({ + ...prev, + [provider]: enabled, + })); + }; + + const providerToModel = { + replicate: selectedModels.replicate, + // vertex: selectedModels.vertex, + openai: selectedModels.openai, + fireworks: selectedModels.fireworks, + fal: selectedModels.fal, + }; + + const handlePromptSubmit = (newPrompt: string) => { + const activeProviders = PROVIDER_ORDER.filter((p) => enabledProviders[p]); + if (activeProviders.length > 0) { + startGeneration(newPrompt, activeProviders, providerToModel); + } + setShowProviders(false); + }; + + return ( +
+
+ + {(() => { + const getModelProps = () => + (Object.keys(PROVIDERS) as ProviderKey[]).map((key) => { + const provider = PROVIDERS[key]; + const imageItem = images.find((img) => img.provider === key); + const imageData = imageItem?.image; + const modelId = imageItem?.modelId ?? 'N/A'; + const timing = timings[key]; + + return { + label: provider.displayName, + models: provider.models, + value: selectedModels[key], + providerKey: key, + onChange: (model: string, providerKey: ProviderKey) => + handleModelChange(providerKey, model), + iconPath: provider.iconPath, + color: provider.color, + enabled: enabledProviders[key], + onToggle: (enabled: boolean) => + handleProviderToggle(key, enabled), + image: imageData, + modelId, + timing, + failed: failedProviders.includes(key), + }; + }); + + return ( + <> +
+ +
+
+ {getModelProps().map((props) => ( + + ))} +
+ {activePrompt && activePrompt.length > 0 && ( +
+ {activePrompt} +
+ )} + + ); + })()} +
+
+ ); +} diff --git a/src/ai/image/components/ModelCardCarousel.tsx b/src/ai/image/components/ModelCardCarousel.tsx new file mode 100644 index 0000000..b32acf3 --- /dev/null +++ b/src/ai/image/components/ModelCardCarousel.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; +import { cn } from '@/lib/utils'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { ProviderTiming } from '../lib/image-types'; +import type { ProviderKey } from '../lib/provider-config'; +import { ModelSelect } from './ModelSelect'; + +interface ModelCardCarouselProps { + models: Array<{ + label: string; + models: string[]; + iconPath: string; + color: string; + value: string; + providerKey: ProviderKey; + enabled?: boolean; + onToggle?: (enabled: boolean) => void; + onChange: (value: string, providerKey: ProviderKey) => void; + image: string | null | undefined; + timing?: ProviderTiming; + failed?: boolean; + modelId: string; + }>; +} + +export function ModelCardCarousel({ models }: ModelCardCarouselProps) { + const [currentSlide, setCurrentSlide] = useState(0); + const [api, setApi] = useState(); + const initialized = useRef(false); + + useLayoutEffect(() => { + if (!api || initialized.current) return; + + // Force scroll in multiple ways + api.scrollTo(0, false); + api.scrollPrev(); // Reset any potential offset + api.scrollTo(0, false); + + initialized.current = true; + setCurrentSlide(0); + }, [api]); + + useEffect(() => { + if (!api) return; + + const onSelect = () => { + setCurrentSlide(api.selectedScrollSnap()); + }; + + api.on('select', onSelect); + return () => { + api.off('select', onSelect); + return; + }; + }, [api]); + + return ( +
+ + + {models.map((model, i) => ( + + + model.onChange(value, providerKey) + } + /> +
+ {i + 1} of {models.length} +
+
+ ))} +
+ + + +
+ + {/* Dot Indicators */} +
+
+ {models.map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/ai/image/components/ModelSelect.tsx b/src/ai/image/components/ModelSelect.tsx new file mode 100644 index 0000000..9580f0b --- /dev/null +++ b/src/ai/image/components/ModelSelect.tsx @@ -0,0 +1,153 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import Link from 'next/link'; +import { imageHelpers } from '../lib/image-helpers'; +import type { ProviderTiming } from '../lib/image-types'; +import { + FireworksIcon, + OpenAIIcon, + ReplicateIcon, + // VertexIcon, + falAILogo, +} from '../lib/logos'; +import type { ProviderKey } from '../lib/provider-config'; +import { ImageDisplay } from './ImageDisplay'; + +interface ModelSelectProps { + label: string; + models: string[]; + value: string; + providerKey: ProviderKey; + onChange: (value: string, providerKey: ProviderKey) => void; + iconPath: string; + color: string; + enabled?: boolean; + onToggle?: (enabled: boolean) => void; + image: string | null | undefined; + timing?: ProviderTiming; + failed?: boolean; + modelId: string; +} + +const PROVIDER_ICONS = { + openai: OpenAIIcon, + replicate: ReplicateIcon, + // vertex: VertexIcon, + fireworks: FireworksIcon, + fal: falAILogo, +} as const; + +const PROVIDER_LINKS = { + openai: 'openai', + replicate: 'replicate', + // vertex: 'google-vertex', + fireworks: 'fireworks', + fal: 'fal', +} as const; + +export function ModelSelect({ + label, + models, + value, + providerKey, + onChange, + enabled = true, + image, + timing, + failed, + modelId, +}: ModelSelectProps) { + const Icon = PROVIDER_ICONS[providerKey]; + + return ( + + +
+
+
+ +
+ +
+ +
+
+ +

{label}

+ +
+ +
+
+
+
+ + +
+
+ ); +} diff --git a/src/ai/image/components/PromptInput.tsx b/src/ai/image/components/PromptInput.tsx new file mode 100644 index 0000000..1e183b3 --- /dev/null +++ b/src/ai/image/components/PromptInput.tsx @@ -0,0 +1,116 @@ +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { ArrowUp, ArrowUpRight, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { type Suggestion, getRandomSuggestions } from '../lib/suggestions'; +import { Spinner } from './spinner'; + +type QualityMode = 'performance' | 'quality'; + +interface PromptInputProps { + onSubmit: (prompt: string) => void; + isLoading?: boolean; + showProviders: boolean; + onToggleProviders: () => void; + mode: QualityMode; + onModeChange: (mode: QualityMode) => void; + suggestions: Suggestion[]; +} + +export function PromptInput({ + suggestions: initSuggestions, + isLoading, + onSubmit, +}: PromptInputProps) { + const [input, setInput] = useState(''); + const [suggestions, setSuggestions] = useState(initSuggestions); + + const updateSuggestions = () => { + setSuggestions(getRandomSuggestions()); + }; + const handleSuggestionSelect = (prompt: string) => { + setInput(prompt); + // onSubmit(prompt); + }; + + const handleSubmit = () => { + if (!isLoading && input.trim()) { + onSubmit(input); + } + }; + + // const handleRefreshSuggestions = () => { + // setCurrentSuggestions(getRandomSuggestions()); + // }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (!isLoading && input.trim()) { + onSubmit(input); + } + } + }; + + return ( +
+
+
+