feat: support ai image generator

This commit is contained in:
javayhu 2025-06-26 00:41:27 +08:00
parent b3180e617d
commit 985579b964
22 changed files with 2118 additions and 26 deletions

View File

@ -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",

287
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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<ProviderKey, ProviderTiming>;
failedProviders: ProviderKey[];
enabledProviders: Record<ProviderKey, boolean>;
providerToModel: Record<ProviderKey, string>;
}
export function ImageCarousel({
providers,
images,
timings,
failedProviders,
enabledProviders,
providerToModel,
}: ImageCarouselProps) {
const [currentSlide, setCurrentSlide] = useState(0);
const [api, setApi] = useState<CarouselApi>();
useEffect(() => {
if (!api) return;
api.on('select', () => {
setCurrentSlide(api.selectedScrollSnap());
});
}, [api]);
return (
<div className="relative w-full">
<Carousel setApi={setApi} opts={{ align: 'start', loop: true }}>
<CarouselContent>
{providers.map((provider, i) => {
const imageData = images?.find(
(img) => img.provider === provider
)?.image;
const timing = timings[provider];
return (
<CarouselItem key={provider}>
<ImageDisplay
modelId={
images?.find((img) => img.provider === provider)?.modelId ||
providerToModel[provider]
}
provider={provider}
image={imageData}
timing={timing}
failed={failedProviders.includes(provider)}
enabled={enabledProviders[provider]}
/>
<div className="text-center text-sm text-muted-foreground mt-4">
{i + 1} of {providers.length}
</div>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
</Carousel>
{/* Dot Indicators */}
<div className="absolute -bottom-6 left-0 right-0">
<div className="flex justify-center gap-1">
{providers.map((_, index) => (
<button
type="button"
key={index}
className={cn(
'h-1.5 rounded-full transition-all',
index === currentSlide
? 'w-4 bg-primary'
: 'w-1.5 bg-primary/50'
)}
onClick={() => api?.scrollTo(index)}
>
<span className="sr-only">Go to image {index + 1}</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -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 (
<>
<div
className={cn(
'relative w-full aspect-square group bg-zinc-50 rounded-lg',
image && !failed && 'cursor-pointer',
(!image || failed) && 'border-1 border-zinc-100'
)}
onClick={handleImageClick}
>
{(image || failed) && (
<div className="absolute top-2 left-2 max-w-[75%] bg-white/95 px-2 py-1 flex items-center gap-2 rounded-lg">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Label className="text-xs text-gray-900 truncate min-w-0 grow">
{imageHelpers.formatModelId(modelId)}
</Label>
</TooltipTrigger>
<TooltipContent>
<p>{modelId}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{image && !failed ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${image}`}
alt={`Generated by ${provider}`}
className="w-full h-full object-cover rounded-lg"
/>
<Button
size="icon"
variant="secondary"
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={(e) => handleActionClick(e, image, provider)}
>
<span className="sm:hidden">
<Share className="h-4 w-4" />
</span>
<span className="hidden sm:block">
<Download className="h-4 w-4" />
</span>
</Button>
{timing?.elapsed && (
<div className="absolute bottom-2 right-2 bg-black/70 backdrop-blur-sm rounded-md px-2 py-1 shadow">
<span className="text-xs text-white/90 font-medium">
{(timing.elapsed / 1000).toFixed(1)}s
</span>
</div>
)}
</>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center">
{failed ? (
fallbackIcon || <AlertCircle className="h-8 w-8 text-red-500" />
) : image ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${image}`}
alt={`Generated by ${provider}`}
className="w-full h-full object-cover rounded-lg"
/>
<Button
size="icon"
variant="secondary"
className="absolute bottom-2 left-2 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
onClick={(e) => handleActionClick(e, image, provider)}
>
<span className="sm:hidden">
<Share className="h-4 w-4" />
</span>
<span className="hidden sm:block">
<Download className="h-4 w-4" />
</span>
</Button>
</>
) : timing?.startTime ? (
<>
{/* <div className="text-zinc-400 mb-2">{provider}</div> */}
<Stopwatch startTime={timing.startTime} />
</>
) : (
<ImageIcon className="h-12 w-12 text-zinc-300" />
)}
</div>
)}
</div>
{isZoomed &&
image &&
createPortal(
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center cursor-pointer min-h-[100dvh] w-screen"
onClick={() => setIsZoomed(false)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/png;base64,${image}`}
alt={`Generated by ${provider}`}
className="max-h-[90dvh] max-w-[90vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body
)}
</>
);
}

View File

@ -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<ProviderKey, ProviderTiming>;
enabledProviders: Record<ProviderKey, boolean>;
toggleView: () => void;
}
export function ImageGenerator({
images,
errors,
failedProviders,
timings,
enabledProviders,
toggleView,
}: ImageGeneratorProps) {
return (
<div className="space-y-6">
{/* If there are errors, render a collapsible alert */}
{errors.length > 0 && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 text-destructive"
>
<AlertCircle className="h-4 w-4" />
{errors.length} {errors.length === 1 ? 'error' : 'errors'}{' '}
occurred
<ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2 mt-2">
{errors.map((err, index) => (
<Alert key={index} variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-3">
<AlertTitle className="capitalize">
{err.provider} Error
</AlertTitle>
<AlertDescription className="mt-1 text-sm">
{err.message}
</AlertDescription>
</div>
</Alert>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">Generated Images</h3>
<Button
variant="outline"
className=""
onClick={() => toggleView()}
size="icon"
>
<Settings className="h-4 w-4" />
</Button>
</div>
{/* Mobile layout: Carousel */}
<div className="sm:hidden">
<ImageCarousel
providers={PROVIDER_ORDER}
images={images}
timings={timings}
failedProviders={failedProviders}
enabledProviders={enabledProviders}
providerToModel={initializeProviderRecord<string>()}
/>
</div>
{/* Desktop layout: Grid */}
<div className="hidden sm:grid sm:grid-cols-2 2xl:grid-cols-4 gap-6">
{PROVIDER_ORDER.map((provider) => {
const imageItem = images.find((img) => img.provider === provider);
const imageData = imageItem?.image;
const timing = timings[provider];
return (
<ImageDisplay
key={provider}
provider={provider}
image={imageData}
timing={timing}
failed={failedProviders.includes(provider)}
enabled={enabledProviders[provider]}
modelId={imageItem?.modelId ?? ''}
/>
);
})}
</div>
</div>
);
}

View File

@ -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<ProviderKey, string>
>(MODEL_CONFIGS.performance);
const [enabledProviders, setEnabledProviders] = useState(
initializeProviderRecord(true)
);
const [mode, setMode] = useState<ModelMode>('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 (
<div className="min-h-screen bg-background py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<PromptInput
onSubmit={handlePromptSubmit}
isLoading={isLoading}
showProviders={showProviders}
onToggleProviders={toggleView}
mode={mode}
onModeChange={handleModeChange}
suggestions={suggestions}
/>
{(() => {
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 (
<>
<div className="md:hidden">
<ModelCardCarousel models={getModelProps()} />
</div>
<div className="hidden md:grid md:grid-cols-2 2xl:grid-cols-4 gap-8">
{getModelProps().map((props) => (
<ModelSelect key={props.label} {...props} />
))}
</div>
{activePrompt && activePrompt.length > 0 && (
<div className="text-center mt-4 text-muted-foreground">
{activePrompt}
</div>
)}
</>
);
})()}
</div>
</div>
);
}

View File

@ -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<CarouselApi>();
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 (
<div className="relative w-full mb-8">
<Carousel
setApi={setApi}
opts={{
align: 'start',
dragFree: false,
containScroll: 'trimSnaps',
loop: true,
}}
>
<CarouselContent>
{models.map((model, i) => (
<CarouselItem key={model.label}>
<ModelSelect
{...model}
onChange={(value, providerKey) =>
model.onChange(value, providerKey)
}
/>
<div className="text-center text-sm text-muted-foreground mt-4">
{i + 1} of {models.length}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-0 bg-background/80 backdrop-blur-sm" />
<CarouselNext className="right-0 bg-background/80 backdrop-blur-sm" />
</Carousel>
{/* Dot Indicators */}
<div className="absolute -bottom-6 left-0 right-0">
<div className="flex justify-center gap-1">
{models.map((_, index) => (
<button
type="button"
key={index}
className={cn(
'h-1.5 rounded-full transition-all',
index === currentSlide
? 'w-4 bg-primary'
: 'w-1.5 bg-primary/50'
)}
onClick={() => api?.scrollTo(index)}
>
<span className="sr-only">Go to model {index + 1}</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -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 (
<Card
className={cn('w-full transition-opacity', enabled ? '' : 'opacity-50')}
>
<CardContent className="pt-6 h-full">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2 w-full transition-opacity duration-200">
<div className="bg-primary p-2 rounded-full">
<Link
className="hover:opacity-80"
href={
'https://sdk.vercel.ai/providers/ai-sdk-providers/' +
PROVIDER_LINKS[providerKey]
}
target="_blank"
>
<div className="text-primary-foreground">
<Icon size={28} />
</div>
</Link>
</div>
<div className="flex flex-col w-full">
<Link
className="hover:opacity-80"
href={
'https://sdk.vercel.ai/providers/ai-sdk-providers/' +
PROVIDER_LINKS[providerKey]
}
target="_blank"
>
<h3 className="font-semibold text-lg">{label}</h3>
</Link>
<div className="flex justify-between items-center w-full">
<Select
defaultValue={value}
value={value}
onValueChange={(selectedValue) =>
onChange(selectedValue, providerKey)
}
>
<SelectTrigger>
<SelectValue placeholder={value || 'Select a model'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{models.map((model) => (
<SelectItem key={model} value={model} className="">
<span className="hidden xl:inline">
{imageHelpers.formatModelId(model).length > 30
? imageHelpers.formatModelId(model).slice(0, 30) +
'...'
: imageHelpers.formatModelId(model)}
</span>
<span className="hidden lg:inline xl:hidden">
{imageHelpers.formatModelId(model).length > 20
? imageHelpers.formatModelId(model).slice(0, 20) +
'...'
: imageHelpers.formatModelId(model)}
</span>
<span className="lg:hidden">
{imageHelpers.formatModelId(model)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<ImageDisplay
modelId={modelId}
provider={providerKey}
image={image}
timing={timing}
failed={failed}
/>
</CardContent>
</Card>
);
}

View File

@ -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<Suggestion[]>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isLoading && input.trim()) {
onSubmit(input);
}
}
};
return (
<div className="w-full mb-8">
<div className="bg-zinc-50 rounded-xl p-4">
<div className="flex flex-col gap-3">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter your prompt here"
rows={3}
className="text-base bg-transparent border-none p-0 resize-none placeholder:text-zinc-500 text-[#111111] focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<div className="flex items-center justify-between pt-1">
<div className="flex items-center justify-between space-x-2">
<button
type="button"
onClick={updateSuggestions}
className="flex items-center justify-between px-2 rounded-lg py-1 bg-background text-sm hover:opacity-70 group transition-opacity duration-200"
>
<RefreshCw className="w-4 h-4 text-zinc-500 group-hover:opacity-70" />
</button>
{suggestions.map((suggestion, index) => (
<button
type="button"
key={index}
onClick={() => handleSuggestionSelect(suggestion.prompt)}
className={cn(
'flex items-center justify-between px-2 rounded-lg py-1 bg-background text-sm hover:opacity-70 group transition-opacity duration-200',
index > 2
? 'hidden md:flex'
: index > 1
? 'hidden sm:flex'
: ''
)}
>
<span>
<span className="text-black text-xs sm:text-sm">
{suggestion.text.toLowerCase()}
</span>
</span>
<ArrowUpRight className="ml-1 h-2 w-2 sm:h-3 sm:w-3 text-zinc-500 group-hover:opacity-70" />
</button>
))}
</div>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !input.trim()}
className="h-8 w-8 rounded-full bg-black flex items-center justify-center disabled:opacity-50"
>
{isLoading ? (
<Spinner className="w-3 h-3 text-white" />
) : (
<ArrowUp className="w-5 h-5 text-white" />
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Lightbulb } from 'lucide-react';
import type { Suggestion } from '../lib/suggestions';
interface PromptSuggestionsProps {
suggestions: Suggestion[];
onSelect: (prompt: string) => void;
disabled?: boolean;
}
export function PromptSuggestions({
suggestions,
onSelect,
disabled = false,
}: PromptSuggestionsProps) {
return (
<div className="relative flex-grow overflow-hidden">
<ScrollArea className="w-full whitespace-nowrap rounded-md">
<div className="flex items-center gap-2">
<div className="flex gap-2 py-1">
{suggestions.map((suggestion) => (
<Button
key={suggestion.text}
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1.5"
disabled={disabled}
onClick={() => onSelect(suggestion.prompt)}
>
<Lightbulb className="h-3.5 w-3.5 text-muted-foreground" />
{suggestion.text}
</Button>
))}
</div>
</div>
<ScrollBar orientation="horizontal" className="h-2.5" />
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import { Zap, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
export type QualityMode = "performance" | "quality";
interface QualityModeToggleProps {
value: QualityMode;
onValueChange: (value: QualityMode) => void;
disabled?: boolean;
}
export function QualityModeToggle({
onValueChange,
disabled = false,
}: QualityModeToggleProps) {
const { toast } = useToast();
return (
<div className="flex flex-col items-center gap-2 min-w-[240px]">
<div className="flex gap-2">
<Button
variant="secondary"
disabled={disabled}
onClick={() => {
onValueChange("performance");
toast({
description: "Switching to faster models for quicker generation",
duration: 2000,
});
}}
>
<Zap className="h-4 w-4 mr-2" />
Performance
</Button>
<Button
variant="secondary"
disabled={disabled}
onClick={() => {
onValueChange("quality");
toast({
description:
"Switching to higher quality models for better results",
duration: 2000,
});
}}
>
<Sparkles className="h-4 w-4 mr-2" />
Quality
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export function Stopwatch({ startTime }: { startTime: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Date.now() - startTime);
}, 100);
return () => clearInterval(interval);
}, [startTime]);
return (
<div className="text-lg text-zinc-500 font-mono">{(elapsed / 1000).toFixed(1)}s</div>
);
}

View File

@ -0,0 +1,5 @@
import { Loader2 } from "lucide-react";
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={`h-4 w-4 animate-spin ${className}`} />;
}

View File

@ -0,0 +1,171 @@
import { useState } from 'react';
import type {
ImageError,
ImageResult,
ProviderTiming,
} from '../lib/image-types';
import {
type ProviderKey,
initializeProviderRecord,
} from '../lib/provider-config';
interface UseImageGenerationReturn {
images: ImageResult[];
errors: ImageError[];
timings: Record<ProviderKey, ProviderTiming>;
failedProviders: ProviderKey[];
isLoading: boolean;
startGeneration: (
prompt: string,
providers: ProviderKey[],
providerToModel: Record<ProviderKey, string>
) => Promise<void>;
resetState: () => void;
activePrompt: string;
}
export function useImageGeneration(): UseImageGenerationReturn {
const [images, setImages] = useState<ImageResult[]>([]);
const [errors, setErrors] = useState<ImageError[]>([]);
const [timings, setTimings] = useState<Record<ProviderKey, ProviderTiming>>(
initializeProviderRecord<ProviderTiming>()
);
const [failedProviders, setFailedProviders] = useState<ProviderKey[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [activePrompt, setActivePrompt] = useState('');
const resetState = () => {
setImages([]);
setErrors([]);
setTimings(initializeProviderRecord<ProviderTiming>());
setFailedProviders([]);
setIsLoading(false);
};
const startGeneration = async (
prompt: string,
providers: ProviderKey[],
providerToModel: Record<ProviderKey, string>
) => {
setActivePrompt(prompt);
try {
setIsLoading(true);
// Initialize images array with null values
setImages(
providers.map((provider) => ({
provider,
image: null,
modelId: providerToModel[provider],
}))
);
// Clear previous state
setErrors([]);
setFailedProviders([]);
// Initialize timings with start times
const now = Date.now();
setTimings(
Object.fromEntries(
providers.map((provider) => [provider, { startTime: now }])
) as Record<ProviderKey, ProviderTiming>
);
// Helper to fetch a single provider
const generateImage = async (provider: ProviderKey, modelId: string) => {
const startTime = now;
console.log(
`Generate image request [provider=${provider}, modelId=${modelId}]`
);
try {
const request = {
prompt,
provider,
modelId,
};
const response = await fetch('/api/generate-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Server error: ${response.status}`);
}
const completionTime = Date.now();
const elapsed = completionTime - startTime;
setTimings((prev) => ({
...prev,
[provider]: {
startTime,
completionTime,
elapsed,
},
}));
console.log(
`Successful image response [provider=${provider}, modelId=${modelId}, elapsed=${elapsed}ms]`
);
// Update image in state
setImages((prevImages) =>
prevImages.map((item) =>
item.provider === provider
? { ...item, image: data.image ?? null, modelId }
: item
)
);
} catch (err) {
console.error(
`Error [provider=${provider}, modelId=${modelId}]:`,
err
);
setFailedProviders((prev) => [...prev, provider]);
setErrors((prev) => [
...prev,
{
provider,
message:
err instanceof Error
? err.message
: 'An unexpected error occurred',
},
]);
setImages((prevImages) =>
prevImages.map((item) =>
item.provider === provider
? { ...item, image: null, modelId }
: item
)
);
}
};
// Generate images for all active providers
const fetchPromises = providers.map((provider) => {
const modelId = providerToModel[provider];
return generateImage(provider, modelId);
});
await Promise.all(fetchPromises);
} catch (error) {
console.error('Error fetching images:', error);
} finally {
setIsLoading(false);
}
};
return {
images,
errors,
timings,
failedProviders,
isLoading,
startGeneration,
resetState,
activePrompt,
};
}

View File

@ -0,0 +1,12 @@
import type { ProviderKey } from './provider-config';
export interface GenerateImageRequest {
prompt: string;
provider: ProviderKey;
modelId: string;
}
export interface GenerateImageResponse {
image?: string;
error?: string;
}

View File

@ -0,0 +1,53 @@
export const imageHelpers = {
base64ToBlob: (base64Data: string, type = "image/png"): Blob => {
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
return new Blob([uint8Array], { type });
},
generateImageFileName: (provider: string): string => {
const uniqueId = Math.random().toString(36).substring(2, 8);
return `${provider}-${uniqueId}`.replace(/[^a-z0-9-]/gi, "");
},
shareOrDownload: async (
imageData: string,
provider: string
): Promise<void> => {
const fileName = imageHelpers.generateImageFileName(provider);
const blob = imageHelpers.base64ToBlob(imageData);
const file = new File([blob], `${fileName}.png`, { type: "image/png" });
try {
if (navigator.share) {
await navigator.share({
files: [file],
title: `Image generated by ${provider}`,
});
} else {
throw new Error("Share API not available");
}
} catch (error) {
// Fall back to download for any error (including share cancellation)
console.error("Error sharing/downloading:", error);
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `${fileName}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
},
formatModelId: (modelId: string): string => {
return modelId.split("/").pop() || modelId;
},
};

View File

@ -0,0 +1,24 @@
import type { ProviderKey } from './provider-config';
export interface GeneratedImage {
provider: ProviderKey;
image: string | null;
modelId?: string;
}
export interface ImageResult {
provider: ProviderKey;
image: string | null;
modelId?: string;
}
export interface ImageError {
provider: ProviderKey;
message: string;
}
export interface ProviderTiming {
startTime?: number;
completionTime?: number;
elapsed?: number;
}

131
src/ai/image/lib/logos.tsx Normal file
View File

@ -0,0 +1,131 @@
export const FireworksIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 638 315"
xmlns="http://www.w3.org/2000/svg"
fill="white"
>
<g transform="scale(0.8) translate(75,-10)">
<path
d="M318.563 221.755C300.863 221.755 284.979 211.247 278.206 194.978L196.549 0H244.342L318.842 178.361L393.273 0H441.066L358.92 195.048C352.112 211.247 336.263 221.755 318.563 221.755Z"
className="fill-logo"
/>
<path
d="M425.111 314.933C407.481 314.933 391.667 304.494 384.824 288.366C377.947 272.097 381.507 253.524 393.936 240.921L542.657 90.2803L561.229 134.094L425.076 271.748L619.147 270.666L637.72 314.479L425.146 315.003L425.076 314.933H425.111Z"
className="fill-logo"
/>
<path
d="M0 314.408L18.5727 270.595L212.643 271.677L76.525 133.988L95.0977 90.1748L243.819 240.816C256.247 253.384 259.843 272.026 252.93 288.26C246.088 304.424 230.203 314.827 212.643 314.827L0.0698221 314.339L0 314.408Z"
className="fill-logo"
/>
</g>
</svg>
);
};
export const OpenAIIcon = ({ size = 16 }) => {
return (
<svg
data-testid="geist-icon"
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
transform="scale(0.8) translate(2,2)"
d="M14.9449 6.54871C15.3128 5.45919 15.1861 4.26567 14.5978 3.27464C13.7131 1.75461 11.9345 0.972595 10.1974 1.3406C9.42464 0.481584 8.3144 -0.00692594 7.15045 7.42132e-05C5.37487 -0.00392587 3.79946 1.1241 3.2532 2.79113C2.11256 3.02164 1.12799 3.72615 0.551837 4.72468C-0.339497 6.24071 -0.1363 8.15175 1.05451 9.45178C0.686626 10.5413 0.813308 11.7348 1.40162 12.7258C2.28637 14.2459 4.06498 15.0279 5.80204 14.6599C6.5743 15.5189 7.68504 16.0074 8.849 15.9999C10.6256 16.0044 12.2015 14.8754 12.7478 13.2069C13.8884 12.9764 14.873 12.2718 15.4491 11.2733C16.3394 9.75728 16.1357 7.84774 14.9454 6.54771L14.9449 6.54871ZM8.85001 14.9544C8.13907 14.9554 7.45043 14.7099 6.90468 14.2604C6.92951 14.2474 6.97259 14.2239 7.00046 14.2069L10.2293 12.3668C10.3945 12.2743 10.4959 12.1008 10.4949 11.9133V7.42173L11.8595 8.19925C11.8742 8.20625 11.8838 8.22025 11.8858 8.23625V11.9558C11.8838 13.6099 10.5263 14.9509 8.85001 14.9544ZM2.32133 12.2028C1.9651 11.5958 1.8369 10.8843 1.95902 10.1938C1.98284 10.2078 2.02489 10.2333 2.05479 10.2503L5.28366 12.0903C5.44733 12.1848 5.65003 12.1848 5.81421 12.0903L9.75604 9.84429V11.3993C9.75705 11.4153 9.74945 11.4308 9.73678 11.4408L6.47295 13.3004C5.01915 14.1264 3.1625 13.6354 2.32184 12.2028H2.32133ZM1.47155 5.24819C1.82626 4.64017 2.38619 4.17516 3.05305 3.93366C3.05305 3.96116 3.05152 4.00966 3.05152 4.04366V7.72424C3.05051 7.91124 3.15186 8.08475 3.31654 8.17725L7.25838 10.4228L5.89376 11.2003C5.88008 11.2093 5.86285 11.2108 5.84765 11.2043L2.58331 9.34327C1.13255 8.51426 0.63494 6.68272 1.47104 5.24869L1.47155 5.24819ZM12.6834 7.82274L8.74157 5.57669L10.1062 4.79968C10.1199 4.79068 10.1371 4.78918 10.1523 4.79568L13.4166 6.65522C14.8699 7.48373 15.3681 9.31827 14.5284 10.7523C14.1732 11.3593 13.6138 11.8243 12.9474 12.0663V8.27575C12.9489 8.08875 12.8481 7.91574 12.6839 7.82274H12.6834ZM14.0414 5.8057C14.0176 5.7912 13.9756 5.7662 13.9457 5.7492L10.7168 3.90916C10.5531 3.81466 10.3504 3.81466 10.1863 3.90916L6.24442 6.15521V4.60017C6.2434 4.58417 6.251 4.56867 6.26367 4.55867L9.52751 2.70063C10.9813 1.87311 12.84 2.36563 13.6781 3.80066C14.0323 4.40667 14.1605 5.11618 14.0404 5.8057H14.0414ZM5.50257 8.57726L4.13744 7.79974C4.12275 7.79274 4.11312 7.77874 4.11109 7.76274V4.04316C4.11211 2.38713 5.47368 1.0451 7.15197 1.0461C7.86189 1.0461 8.54902 1.2921 9.09476 1.74011C9.06993 1.75311 9.02737 1.77661 8.99899 1.79361L5.77012 3.63365C5.60493 3.72615 5.50358 3.89916 5.50459 4.08666L5.50257 8.57626V8.57726ZM6.24391 7.00022L7.99972 5.9997L9.75553 6.99972V9.00027L7.99972 10.0003L6.24391 9.00027V7.00022Z"
fill="currentColor"
/>
</svg>
);
};
export const ReplicateIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 1500 1500"
xmlns="http://www.w3.org/2000/svg"
style={{ color: 'currentcolor' }}
>
<g fill="white" transform="scale(0.8) translate(450,450)">
<polygon points="1000,427.6 1000,540.6 603.4,540.6 603.4,1000 477,1000 477,427.6" />
<polygon points="1000,213.8 1000,327 364.8,327 364.8,1000 238.4,1000 238.4,213.8" />
<polygon points="1000,0 1000,113.2 126.4,113.2 126.4,1000 0,1000 0,0" />
</g>
</svg>
);
};
export const VertexIcon = ({ size = 16 }) => {
return (
<svg
height={size}
width={size}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
style={{ color: 'currentcolor' }}
>
<g transform="scale(0.8) translate(65,65)">
<path
d="M128,249c-8.8,0-16-7.2-16-16v-105c0-8.8,7.2-16,16-16s16,7.2,16,16v105c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<path
d="M256,464c-3,0-6-.8-8.6-2.5l-176-112c-7.5-4.7-9.7-14.6-4.9-22.1,4.8-7.5,14.6-9.6,22.1-4.9l167.4,106.5,167.4-106.5c7.5-4.7,17.3-2.5,22.1,4.9,4.7,7.5,2.5,17.3-4.9,22.1l-176,112c-2.6,1.7-5.6,2.5-8.6,2.5h0Z"
fill="white"
/>
<path
d="M256,394c-8.8,0-16-7.2-16-16v-73.1c0-8.8,7.2-16,16-16s16,7.2,16,16v73.1c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="128" cy="64" r="16" fill="white" />
<circle cx="128" cy="297" r="16" fill="white" />
<path
d="M384.2,314c-8.8,0-16-7.1-16-16l-.2-106c0-8.8,7.1-16,16-16h0c8.8,0,16,7.1,16,16l.2,106c0,8.8-7.1,16-16,16h0Z"
fill="white"
/>
<circle cx="384" cy="64" r="16" fill="white" />
<circle cx="384" cy="128" r="16" fill="white" />
<path
d="M320,225c-8.8,0-16-7.2-16-16v-103c0-8.8,7.2-16,16-16s16,7.2,16,16v103c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="256" cy="177" r="16" fill="white" />
<circle cx="256" cy="241" r="16" fill="white" />
<circle cx="320" cy="273" r="16" fill="white" />
<circle cx="320" cy="337" r="16" fill="white" />
<path
d="M192,225c-8.8,0-16-7.2-16-16v-103c0-8.8,7.2-16,16-16s16,7.2,16,16v103c0,8.8-7.2,16-16,16Z"
fill="white"
/>
<circle cx="192" cy="273" r="16" fill="white" />
<circle cx="192" cy="337" r="16" fill="white" />
</g>
</svg>
);
};
export const falAILogo = ({ size = 16 }: { size: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 170 171"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M109.571 0.690002C112.515 0.690002 114.874 3.08348 115.155 6.01352C117.665 32.149 138.466 52.948 164.603 55.458C167.534 55.7394 169.927 58.0985 169.927 61.042V110.255C169.927 113.198 167.534 115.557 164.603 115.839C138.466 118.349 117.665 139.148 115.155 165.283C114.874 168.213 112.515 170.607 109.571 170.607H60.3553C57.4116 170.607 55.0524 168.213 54.7709 165.283C52.2608 139.148 31.4601 118.349 5.32289 115.839C2.39266 115.557 -0.000976562 113.198 -0.000976562 110.255V61.042C-0.000976562 58.0985 2.39267 55.7394 5.3229 55.458C31.4601 52.948 52.2608 32.149 54.7709 6.01351C55.0524 3.08348 57.4116 0.690002 60.3553 0.690002H109.571ZM34.1182 85.5045C34.1182 113.776 57.0124 136.694 85.2539 136.694C113.495 136.694 136.39 113.776 136.39 85.5045C136.39 57.2332 113.495 34.3147 85.2539 34.3147C57.0124 34.3147 34.1182 57.2332 34.1182 85.5045Z"
fill="currentColor"
/>
</svg>
);
};

View File

@ -0,0 +1,106 @@
export type ProviderKey =
| 'replicate'
// | 'vertex'
| 'openai'
| 'fireworks'
| 'fal';
export type ModelMode = 'performance' | 'quality';
export const PROVIDERS: Record<
ProviderKey,
{
displayName: string;
iconPath: string;
color: string;
models: string[];
}
> = {
replicate: {
displayName: 'Replicate',
iconPath: '/provider-icons/replicate.svg',
color: 'from-purple-500 to-blue-500',
models: [
'black-forest-labs/flux-1.1-pro',
'black-forest-labs/flux-1.1-pro-ultra',
'black-forest-labs/flux-dev',
'black-forest-labs/flux-pro',
'black-forest-labs/flux-schnell',
'ideogram-ai/ideogram-v2',
'ideogram-ai/ideogram-v2-turbo',
'luma/photon',
'luma/photon-flash',
'recraft-ai/recraft-v3',
'stability-ai/stable-diffusion-3.5-large',
'stability-ai/stable-diffusion-3.5-large-turbo',
],
},
// vertex: {
// displayName: 'Vertex AI',
// iconPath: '/provider-icons/vertex.svg',
// color: 'from-green-500 to-emerald-500',
// models: ['imagen-3.0-generate-001', 'imagen-3.0-fast-generate-001'],
// },
openai: {
displayName: 'OpenAI',
iconPath: '/provider-icons/openai.svg',
color: 'from-blue-500 to-cyan-500',
models: ['dall-e-2', 'dall-e-3'],
},
fireworks: {
displayName: 'Fireworks',
iconPath: '/provider-icons/fireworks.svg',
color: 'from-orange-500 to-red-500',
models: [
'accounts/fireworks/models/flux-1-dev-fp8',
'accounts/fireworks/models/flux-1-schnell-fp8',
'accounts/fireworks/models/playground-v2-5-1024px-aesthetic',
'accounts/fireworks/models/japanese-stable-diffusion-xl',
'accounts/fireworks/models/playground-v2-1024px-aesthetic',
'accounts/fireworks/models/SSD-1B',
'accounts/fireworks/models/stable-diffusion-xl-1024-v1-0',
],
},
fal: {
displayName: 'Fal',
iconPath: '/provider-icons/fal.svg',
color: 'from-orange-500 to-red-500',
models: [
'fal-ai/flux/dev',
'fal-ai/fast-sdxl',
'fal-ai/flux-pro/v1.1-ultra',
'fal-ai/ideogram/v2',
'fal-ai/recraft-v3',
'fal-ai/hyper-sdxl',
],
},
};
export const MODEL_CONFIGS: Record<ModelMode, Record<ProviderKey, string>> = {
performance: {
replicate: 'stability-ai/stable-diffusion-3.5-large-turbo',
// vertex: 'imagen-3.0-fast-generate-001',
openai: 'dall-e-2',
fireworks: 'accounts/fireworks/models/flux-1-schnell-fp8',
fal: 'fal-ai/flux/dev',
},
quality: {
replicate: 'stability-ai/stable-diffusion-3.5-large',
// vertex: 'imagen-3.0-generate-001',
openai: 'dall-e-3',
fireworks: 'accounts/fireworks/models/flux-1-dev-fp8',
fal: 'fal-ai/flux-pro/v1.1-ultra',
},
};
export const PROVIDER_ORDER: ProviderKey[] = [
'replicate',
// 'vertex',
'openai',
'fireworks',
'fal',
];
export const initializeProviderRecord = <T>(defaultValue?: T) =>
Object.fromEntries(
PROVIDER_ORDER.map((key) => [key, defaultValue])
) as Record<ProviderKey, T>;

View File

@ -0,0 +1,138 @@
export interface Suggestion {
text: string;
prompt: string;
}
const artStyles = ['anime', 'art nouveau', 'ukiyo-e', 'watercolor'];
const basePrompts: { text: string; prompt: string }[] = [
{
text: 'Salamander Dusk',
prompt: 'A salamander at dusk in a forest pond',
},
{
text: 'Sultry Chicken',
prompt:
'A sultry chicken peering around the corner from shadows, clearly up to no good',
},
{
text: 'Cat Vercel',
prompt: 'A cat launching its website on Vercel',
},
{
text: 'Red Panda',
prompt:
'A red panda sipping tea under cherry blossoms at sunset with Mount Fuji in the background',
},
{
text: 'Beach Otter',
prompt: 'A mischievous otter surfing the waves in Bali at golden hour',
},
{
text: 'Badger Ramen',
prompt: 'A pensive honey badger eating a bowl of ramen in Osaka',
},
{
text: 'Zen Frog',
prompt:
'A frog meditating on a lotus leaf in a tranquil forest pond at dawn, surrounded by fireflies',
},
{
text: 'Macaw Love',
prompt:
'A colorful macaw delivering a love letter, flying over the Grand Canyon at sunrise',
},
{
text: 'Fox Painting',
prompt: 'A fox walking through a field of lavender with a golden sunset',
},
{
text: 'Armadillo Aerospace',
prompt:
'An armadillo in a rocket at countdown preparing to blast off to Mars',
},
{
text: 'Penguin Delight',
prompt: 'A penguin in pajamas eating ice cream while watching television',
},
{
text: 'Echidna Library',
prompt:
'An echidna reading a book in a cozy library built into the branches of a eucalyptus tree',
},
{
text: 'Capybara Onsen',
prompt:
'A capybara relaxing in a hot spring surrounded by snow-covered mountains with a waterfall in the background',
},
{
text: 'Lion Throne',
prompt:
'A regal lion wearing a crown, sitting on a throne in a jungle palace, with waterfalls in the distance',
},
{
text: 'Dolphin Glow',
prompt:
'A dolphin leaping through a glowing ring of bioluminescence under a starry sky',
},
{
text: 'Owl Detective',
prompt:
'An owl wearing a monocle and top hat, solving a mystery in a misty forest at midnight',
},
{
text: 'Jellyfish Cathedral',
prompt:
'A jellyfish floating gracefully in an underwater cathedral made of coral and glass',
},
{
text: 'Platypus River',
prompt: 'A platypus foraging in a river with a sunset in the background',
},
{
text: 'Chameleon Urban',
prompt:
'A chameleon blending into a graffiti-covered wall in an urban jungle',
},
{
text: 'Tortoise Oasis',
prompt:
'A giant tortoise slowly meandering its way to an oasis in the desert',
},
{
text: 'Hummingbird Morning',
prompt:
'A hummingbird sipping nectar from a purple bougainvillea at sunrise, captured mid-flight',
},
{
text: 'Polar Bear',
prompt:
'A polar bear clambering onto an iceberg to greet a friendly harbor seal as dusk falls',
},
{
text: 'Lemur Sunbathing',
prompt:
'A ring-tailed lemur sunbathing on a rock in Madagascar in early morning light',
},
];
function shuffle<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
export function getRandomSuggestions(count = 5): Suggestion[] {
const shuffledPrompts = shuffle(basePrompts);
const shuffledStyles = shuffle(artStyles);
return shuffledPrompts.slice(0, count).map((item, index) => ({
text: item.text,
prompt: `${item.prompt}, in the style of ${
shuffledStyles[index % shuffledStyles.length]
}`,
}));
}

View File

@ -1,4 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Metadata } from 'next';
@ -25,31 +26,8 @@ export default async function AIImagePage() {
const t = await getTranslations('AIImagePage');
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* about section */}
<div className="relative max-w-(--breakpoint-md) mx-auto mb-24 mt-8 md:mt-16">
<div className="mx-auto flex flex-col justify-between">
<div className="flex flex-row items-center gap-8">
{/* avatar and name */}
<div className="flex items-center gap-8">
<Avatar className="size-32 p-0.5">
<AvatarImage
className="rounded-full border-4 border-gray-200"
src="/logo.png"
alt="Avatar"
/>
<AvatarFallback>
<div className="size-32 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-4xl text-foreground">{t('content')}</h1>
</div>
</div>
</div>
</div>
</div>
<div className="mx-auto space-y-8">
<ImagePlayground suggestions={getRandomSuggestions(5)} />
</div>
);
}

View File

@ -0,0 +1,124 @@
import type { GenerateImageRequest } from '@/ai/image/lib/api-types';
import type { ProviderKey } from '@/ai/image/lib/provider-config';
import { createFal } from '@ai-sdk/fal';
import { fireworks } from '@ai-sdk/fireworks';
import { openai } from '@ai-sdk/openai';
import { replicate } from '@ai-sdk/replicate';
import {
type ImageModel,
experimental_generateImage as generateImage,
} from 'ai';
import { type NextRequest, NextResponse } from 'next/server';
/**
* Intended to be slightly less than the maximum execution time allowed by the
* runtime so that we can gracefully terminate our request.
*/
const TIMEOUT_MILLIS = 55 * 1000;
const DEFAULT_IMAGE_SIZE = '1024x1024';
const DEFAULT_ASPECT_RATIO = '1:1';
const fal = createFal({
apiKey: process.env.FAL_API_KEY,
});
interface ProviderConfig {
createImageModel: (modelId: string) => ImageModel;
dimensionFormat: 'size' | 'aspectRatio';
}
const providerConfig: Record<ProviderKey, ProviderConfig> = {
openai: {
createImageModel: openai.image,
dimensionFormat: 'size',
},
fireworks: {
createImageModel: fireworks.image,
dimensionFormat: 'aspectRatio',
},
replicate: {
createImageModel: replicate.image,
dimensionFormat: 'size',
},
fal: {
createImageModel: fal.image,
dimensionFormat: 'size',
},
};
const withTimeout = <T>(
promise: Promise<T>,
timeoutMillis: number
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMillis)
),
]);
};
export async function POST(req: NextRequest) {
const requestId = Math.random().toString(36).substring(7);
const { prompt, provider, modelId } =
(await req.json()) as GenerateImageRequest;
try {
if (!prompt || !provider || !modelId || !providerConfig[provider]) {
const error = 'Invalid request parameters';
console.error(`${error} [requestId=${requestId}]`);
return NextResponse.json({ error }, { status: 400 });
}
const config = providerConfig[provider];
const startstamp = performance.now();
const generatePromise = generateImage({
model: config.createImageModel(modelId),
prompt,
...(config.dimensionFormat === 'size'
? { size: DEFAULT_IMAGE_SIZE }
: { aspectRatio: DEFAULT_ASPECT_RATIO }),
...(provider !== 'openai' && {
seed: Math.floor(Math.random() * 1000000),
}),
// Vertex AI only accepts a specified seed if watermark is disabled.
providerOptions: { vertex: { addWatermark: false } },
}).then(({ image, warnings }) => {
if (warnings?.length > 0) {
console.warn(
`Warnings [requestId=${requestId}, provider=${provider}, model=${modelId}]: `,
warnings
);
}
console.log(
`Completed image request [requestId=${requestId}, provider=${provider}, model=${modelId}, elapsed=${(
(performance.now() - startstamp) / 1000
).toFixed(1)}s].`
);
return {
provider,
image: image.base64,
};
});
const result = await withTimeout(generatePromise, TIMEOUT_MILLIS);
return NextResponse.json(result, {
status: 'image' in result ? 200 : 500,
});
} catch (error) {
// Log full error detail on the server, but return a generic error message
// to avoid leaking any sensitive information to the client.
console.error(
`Error generating image [requestId=${requestId}, provider=${provider}, model=${modelId}]: `,
error
);
return NextResponse.json(
{
error: 'Failed to generate image. Please try again later.',
},
{ status: 500 }
);
}
}