feat: support ai image generator
This commit is contained in:
parent
b3180e617d
commit
985579b964
@ -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
287
pnpm-lock.yaml
generated
@ -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
|
||||
|
102
src/ai/image/components/ImageCarousel.tsx
Normal file
102
src/ai/image/components/ImageCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
195
src/ai/image/components/ImageDisplay.tsx
Normal file
195
src/ai/image/components/ImageDisplay.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
120
src/ai/image/components/ImageGenerator.tsx
Normal file
120
src/ai/image/components/ImageGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
138
src/ai/image/components/ImagePlayground.tsx
Normal file
138
src/ai/image/components/ImagePlayground.tsx
Normal 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>
|
||||
);
|
||||
}
|
119
src/ai/image/components/ModelCardCarousel.tsx
Normal file
119
src/ai/image/components/ModelCardCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
153
src/ai/image/components/ModelSelect.tsx
Normal file
153
src/ai/image/components/ModelSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
116
src/ai/image/components/PromptInput.tsx
Normal file
116
src/ai/image/components/PromptInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
src/ai/image/components/PromptSuggestions.tsx
Normal file
43
src/ai/image/components/PromptSuggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
56
src/ai/image/components/QualityModeToggle.tsx
Normal file
56
src/ai/image/components/QualityModeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
17
src/ai/image/components/Stopwatch.tsx
Normal file
17
src/ai/image/components/Stopwatch.tsx
Normal 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>
|
||||
);
|
||||
}
|
5
src/ai/image/components/spinner.tsx
Normal file
5
src/ai/image/components/spinner.tsx
Normal 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}`} />;
|
||||
}
|
171
src/ai/image/hooks/use-image-generation.ts
Normal file
171
src/ai/image/hooks/use-image-generation.ts
Normal 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,
|
||||
};
|
||||
}
|
12
src/ai/image/lib/api-types.ts
Normal file
12
src/ai/image/lib/api-types.ts
Normal 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;
|
||||
}
|
53
src/ai/image/lib/image-helpers.ts
Normal file
53
src/ai/image/lib/image-helpers.ts
Normal 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;
|
||||
},
|
||||
};
|
24
src/ai/image/lib/image-types.ts
Normal file
24
src/ai/image/lib/image-types.ts
Normal 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
131
src/ai/image/lib/logos.tsx
Normal 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>
|
||||
);
|
||||
};
|
106
src/ai/image/lib/provider-config.ts
Normal file
106
src/ai/image/lib/provider-config.ts
Normal 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>;
|
138
src/ai/image/lib/suggestions.ts
Normal file
138
src/ai/image/lib/suggestions.ts
Normal 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]
|
||||
}`,
|
||||
}));
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
124
src/app/api/generate-images/route.ts
Normal file
124
src/app/api/generate-images/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user