Compare commits

...

217 Commits

Author SHA1 Message Date
SwiftyOS
30a047eac3 signup now redirects to edit profile page 2024-12-02 16:59:28 +01:00
SwiftyOS
f2387147c7 fix tests 2024-12-02 16:59:12 +01:00
SwiftyOS
0f77f931ab Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-12-02 16:55:07 +01:00
SwiftyOS
fdb82eda38 Added deleting submissions and viewing the popout 2024-12-02 16:54:56 +01:00
Bently
53eee63161 feat(platform): Adds rating card (#8840)
This adds the rating card, currently i just make it show in every agent
page till we have better logic for it + we want to make it show on the
block builder when we are looking at some one else's agent from the
marketplace, this is to be implemented soon

The Tutorial and ? buttons will also be hidden on the market place pages
so ignore them showing for now

### Changes 🏗️

![image](https://github.com/user-attachments/assets/7bd0314e-6c47-4c58-a113-1ea421f8c257)

![image](https://github.com/user-attachments/assets/1b5f5244-9387-4bbc-ba52-0de1c9dcd3d6)
2024-12-02 11:17:22 +01:00
SwiftyOS
48d27c91d4 update lock files 2024-12-02 09:30:56 +01:00
SwiftyOS
39c9e4a76c Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components 2024-12-02 09:24:54 +01:00
SwiftyOS
b3fd8bbfb9 added option to change password in settings 2024-12-02 09:21:20 +01:00
SwiftyOS
a4b186cf81 add sorting of creators too 2024-11-29 16:16:47 +01:00
SwiftyOS
a971d59974 handle sorting of agents 2024-11-29 16:13:05 +01:00
SwiftyOS
9db8832d6b fixed agent images not showing 2024-11-29 16:03:21 +01:00
SwiftyOS
acb35c3926 tweak layout 2024-11-29 15:50:44 +01:00
SwiftyOS
cd9c4218b0 only show agents that have no store listing 2024-11-29 15:45:59 +01:00
SwiftyOS
3ccf0138b1 remove notification section of settings page 2024-11-29 15:32:57 +01:00
SwiftyOS
19ff8f324d normalise styling between agent and creator page 2024-11-29 15:30:53 +01:00
SwiftyOS
f445918abf comment out arrow buttons on breadcrumbs 2024-11-29 15:13:12 +01:00
SwiftyOS
5a4083d542 Added tooltip for credits button 2024-11-29 15:10:21 +01:00
SwiftyOS
1e66137c7e more dark mode colors 2024-11-29 10:09:26 +01:00
SwiftyOS
bb13157d18 formatting 2024-11-28 16:14:02 +01:00
SwiftyOS
37e6b6f385 Add dark mode to flow and utils 2024-11-28 16:13:40 +01:00
SwiftyOS
fabf742601 updated node hadles dark mode 2024-11-28 15:36:23 +01:00
SwiftyOS
100b667afc updated node input dark mode 2024-11-28 15:36:04 +01:00
SwiftyOS
da10f1a2df updated custom node dark mode 2024-11-28 15:35:51 +01:00
SwiftyOS
65ada3fb72 Update Save Control 2024-11-28 14:20:50 +01:00
SwiftyOS
bc277acf57 Control Panel Dark mode 2024-11-28 14:17:05 +01:00
SwiftyOS
52d19d084c Blocks control dark mode 2024-11-28 14:16:47 +01:00
SwiftyOS
86a10858bd formatting 2024-11-28 12:09:23 +01:00
SwiftyOS
f297e42be4 dark mode color 2024-11-28 12:09:04 +01:00
SwiftyOS
2bbac5714f Search results styling and filter functionality 2024-11-28 11:00:20 +01:00
SwiftyOS
e063f4bcda Navbar publish agent button hooked up 2024-11-28 10:12:01 +01:00
SwiftyOS
6c64c5b98f OPEN-2127 make tutorial button show only in the builder 2024-11-28 09:57:30 +01:00
SwiftyOS
5530db63de Fix styling on home page 2024-11-28 09:43:35 +01:00
SwiftyOS
4278ae61b0 hooked up become a creator 2024-11-28 09:00:32 +01:00
SwiftyOS
0c78edb592 Added logout 2024-11-28 09:00:23 +01:00
Bently
93d3bd3773 feat(platform): Tweaks to Search page (#8806)
* Update to make it properly show "No results found" and look better

* fix text spacing under SearchFilterChips

* add flex to spacing
2024-11-27 13:57:16 +01:00
Bently
c1c3fd4982 feat(platform): Updates to Search page (#8769)
Updates to search page
2024-11-27 11:55:51 +01:00
SwiftyOS
d4b69d864f formatting 2024-11-27 11:53:09 +01:00
SwiftyOS
db3284830a getting dashboard working 2024-11-27 11:52:50 +01:00
SwiftyOS
d16cf6cfeb add slug 2024-11-27 11:41:02 +01:00
SwiftyOS
3f3919a843 Create store agent now working 2024-11-27 11:38:10 +01:00
SwiftyOS
244171d748 wip creat agent 2024-11-26 17:37:28 +01:00
SwiftyOS
faa683b6e4 update lock file 2024-11-26 11:37:20 +01:00
SwiftyOS
9255759e1e Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components 2024-11-26 11:36:31 +01:00
SwiftyOS
abbed4051d dashboard updates 2024-11-26 11:31:05 +01:00
SwiftyOS
8c5380d4f9 fix creator page layout 2024-11-26 11:16:00 +01:00
SwiftyOS
cacc6e1f86 file uploads working 2024-11-26 11:12:46 +01:00
SwiftyOS
9616baf695 switch to gcs 2024-11-26 11:04:08 +01:00
Bently
518f196e6b feat(platform): Updates to Home page (#8753)
* feat(platform): Updates to Home page

* remove left over variant's
2024-11-25 15:13:26 +01:00
SwiftyOS
a9693b582f profile page working 2024-11-25 12:16:20 +01:00
SwiftyOS
64fcba3f3a updates 2024-11-25 11:08:21 +01:00
SwiftyOS
a53f3f0e0a dark mode tweaks 2024-11-21 13:27:26 +01:00
SwiftyOS
84cdf189f4 udpate z level so it is always ontop 2024-11-21 13:24:33 +01:00
SwiftyOS
610a5b9943 Added theme toggle, fixed navbar 2024-11-21 13:19:04 +01:00
SwiftyOS
18b5f2047c update poetry locl 2024-11-21 12:15:40 +01:00
SwiftyOS
17db193faa Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-21 12:13:20 +01:00
SwiftyOS
78476630cd fix migrations 2024-11-20 12:47:35 +01:00
SwiftyOS
7a41f36b13 update lock file 2024-11-20 12:32:07 +01:00
SwiftyOS
c315b8e700 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-20 12:31:40 +01:00
Bently
f6c1bdccac feat(platform): Updates to agent dashboard (#8598)
* Updates to agent dashboard

* fix squished "select all" text in table

* fix table alignment

---------

Co-authored-by: Swifty <craigswift13@gmail.com>
2024-11-20 11:01:15 +01:00
Bently
afe5c12afb feat(platform): Updates to Agent Page (#8664)
Co-authored-by: Swifty <craigswift13@gmail.com>
2024-11-20 10:28:26 +01:00
Bently
65344b9783 feat(platform): Updates to Profile and Settings page (#8694) 2024-11-20 09:59:40 +01:00
SwiftyOS
9f71fb940d update poetry lock 2024-11-15 12:08:01 +01:00
SwiftyOS
28a327c57a merge in dev 2024-11-15 12:07:44 +01:00
SwiftyOS
6aba1bce62 fmt 2024-11-13 16:41:24 +01:00
SwiftyOS
5db220c568 tidy up 2024-11-13 16:40:56 +01:00
SwiftyOS
8e63a4a8d7 Add outline of the search results page 2024-11-13 16:37:37 +01:00
SwiftyOS
175f17b131 wip adding dashboard, profile and settings page 2024-11-13 15:15:26 +01:00
SwiftyOS
9aec1f51ed integrated navbar 2024-11-13 14:28:15 +01:00
SwiftyOS
760e2ff592 delete api pages 2024-11-13 09:58:45 +01:00
SwiftyOS
d17ea2d62a Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-11-13 09:55:38 +01:00
SwiftyOS
6351ba7f5d update locks 2024-11-13 09:55:28 +01:00
SwiftyOS
a1ba3b1ac3 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-13 09:53:34 +01:00
Bently
d78b4d9ab4 feat(platform): Updates to Profile popout menu (#8629)
Co-authored-by: Swifty <craigswift13@gmail.com>
2024-11-12 16:31:44 +01:00
SwiftyOS
1292c85d2a Delete creator details 2024-11-12 13:43:39 +01:00
SwiftyOS
c44fd7332c update creator page 2024-11-12 13:35:50 +01:00
Bently
45a2826df8 feat(platform): Updates to Creator page (#8624)
feat(platform): Updates to creator page

Co-authored-by: Swifty <craigswift13@gmail.com>
2024-11-12 13:22:37 +01:00
SwiftyOS
abb8134761 update search url 2024-11-12 11:42:29 +01:00
SwiftyOS
c879599871 fix migration 2024-11-12 11:42:20 +01:00
SwiftyOS
a71b2a1de6 hooking up pages, added missing update at 2024-11-12 11:15:14 +01:00
SwiftyOS
38b20e6158 remove use client 2024-11-12 09:49:36 +01:00
SwiftyOS
a8a0da1e3c add pages and update links 2024-11-12 09:18:19 +01:00
SwiftyOS
d3e7aab796 end-to-end working 2024-11-11 15:37:10 +01:00
SwiftyOS
f539c24571 wip 2024-11-11 11:29:01 +01:00
SwiftyOS
2068073e8c propgate removal of graph parents 2024-11-11 10:10:26 +01:00
SwiftyOS
04d36194c9 add test data generator 2024-11-11 10:08:23 +01:00
SwiftyOS
e8eda51b27 updated execpetion handling 2024-11-11 09:13:27 +01:00
SwiftyOS
aa5d304a2e Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-11 09:05:37 +01:00
SwiftyOS
9dab7c9132 fix url sacnitization 2024-11-08 14:09:20 +01:00
SwiftyOS
4562606c54 fix liniting formatting, and icon selection in the frontend 2024-11-08 14:07:35 +01:00
SwiftyOS
fa933ada85 fmt 2024-11-08 13:56:43 +01:00
SwiftyOS
bc1d11bf42 isort fmt 2024-11-08 13:36:09 +01:00
SwiftyOS
5ac2e7044e Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-11-08 13:31:17 +01:00
SwiftyOS
7f741468dd add views and db tests 2024-11-08 13:31:10 +01:00
Swifty
715e4c7d73 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-08 12:58:13 +01:00
SwiftyOS
ef473bbc8d updated migration 2024-11-08 12:57:49 +01:00
SwiftyOS
43ac5e0343 formatting 2024-11-08 12:55:22 +01:00
SwiftyOS
da15408a35 add model tests 2024-11-08 12:54:07 +01:00
SwiftyOS
bd00338690 Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-11-08 12:48:26 +01:00
SwiftyOS
c63ccb5bd9 Added backend 2024-11-08 12:46:54 +01:00
Swifty
0e9906ea65 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-07 18:34:17 +01:00
SwiftyOS
ff0e786202 Formatting and added migration 2024-11-07 18:32:44 +01:00
SwiftyOS
deee943c3a Refactoring the v2 api code 2024-11-07 18:30:17 +01:00
SwiftyOS
40f38fcb46 Adding api endpoints 2024-11-07 11:51:48 +01:00
SwiftyOS
35ec676f35 Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-11-07 11:51:15 +01:00
SwiftyOS
c345a79962 Updated Schema to include user groups 2024-11-07 09:55:32 +01:00
Bently
b9c7d1a115 feat(platform): Add settings page (#8576)
Add settings input form and settings page
2024-11-07 09:43:56 +01:00
Bently
bf459a17ba feat(platform): Profile page v2 (#8518)
* Initial changes

* fix save button text

* fix nav bar moving off screen on profile page

* Update sidebar to use slider icon for settings + add slider icon to icons.tsx

* formatting

* remove ProfileNavBar and replace with original Navbar + added credits card to original navbar + fix navbar and sidebar on smaller screens for profile page

---------

Co-authored-by: Swifty <craigswift13@gmail.com>
2024-11-07 09:43:11 +01:00
SwiftyOS
15befae65f wip server 2024-11-06 16:15:46 +01:00
SwiftyOS
2340e9b3f5 Added AuthProvider 2024-11-06 13:48:30 +01:00
SwiftyOS
7044782689 update lock files 2024-11-06 10:23:44 +01:00
SwiftyOS
ef4776f697 revert changes to docker file 2024-11-06 10:17:23 +01:00
SwiftyOS
946ba02969 revent changes to docker file 2024-11-06 10:16:29 +01:00
SwiftyOS
9a6ff408b4 remove docker compose full file 2024-11-06 10:14:02 +01:00
SwiftyOS
c07ea4f63d remove diff 2024-11-06 10:13:06 +01:00
SwiftyOS
2af974b381 remove diff 2024-11-06 10:12:33 +01:00
SwiftyOS
7e6bdf8b04 Merge branch 'swiftyos/open-1993-add-rsc-and-mocking-of-api-to-storybooks' into swiftyos/open-1920-marketplace-home-components 2024-11-06 10:10:17 +01:00
SwiftyOS
707c485212 Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components 2024-11-06 09:59:16 +01:00
SwiftyOS
5145aa7609 adding store endpoints 2024-11-05 16:01:18 +01:00
SwiftyOS
496990c096 Merge branch 'docker-qol' into swiftyos/open-1993-add-rsc-and-mocking-of-api-to-storybooks 2024-11-05 10:30:00 +01:00
SwiftyOS
52de22469f added comments 2024-11-05 10:11:33 +01:00
SwiftyOS
c27f163623 removed all services that are not strictly necessary for running the platform 2024-11-05 10:09:07 +01:00
SwiftyOS
29a61abfe3 Merge branch 'swiftyos/open-1920-marketplace-home-components' into swiftyos/open-1993-add-rsc-and-mocking-of-api-to-storybooks 2024-11-04 11:47:10 +01:00
SwiftyOS
1580fb5fa7 update yarn lock again 2024-11-01 15:07:11 +00:00
SwiftyOS
b9763aa28a Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-11-01 15:06:04 +00:00
SwiftyOS
f9eefae1ad update yarn lock 2024-11-01 15:05:48 +00:00
Swifty
d16c1f259b Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-11-01 14:59:59 +00:00
SwiftyOS
f0650d1f76 dont want to lose thse 2024-10-30 17:04:43 +00:00
SwiftyOS
d9710ce1af Added RSC and update filter chips and search bar to work as expected 2024-10-29 11:45:59 +01:00
SwiftyOS
e5a4b9a5ac Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-10-29 11:00:08 +01:00
Bently
ebad48481e feat(platform): Add Publish agent flow section (#8462)
* Publish agents select page

* updates to new design

* made agent selection be dynamic based on screen size

* add new line for no agents message

* add accessibility

* add Publish Agent Info screen

* add Publish Agent Awaiting Review page

* Fixes for smaller screen sizes

* update to use agptui/Button for buttons

* move svgs to components/ui/icons.tsx
2024-10-29 09:12:51 +01:00
Swifty
4503dab267 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-10-29 08:23:18 +01:00
SwiftyOS
d73acd13cb tweaking feature card 2024-10-28 15:55:19 +01:00
SwiftyOS
a955794acd updated featured store card 2024-10-28 11:56:27 +01:00
SwiftyOS
03d754cb50 update featured agent and formatting 2024-10-28 10:49:51 +01:00
SwiftyOS
8dc22e2a63 updated navbar styling 2024-10-28 10:02:56 +01:00
SwiftyOS
bacdc190e7 Updated hero section for new design 2024-10-28 09:57:17 +01:00
SwiftyOS
5943c75873 Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components 2024-10-28 09:36:12 +01:00
SwiftyOS
8db695932e storing notes on api schema 2024-10-24 16:13:41 +02:00
SwiftyOS
a70d6a5193 Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components 2024-10-24 11:55:40 +02:00
SwiftyOS
4869a8ce22 migrating project to nextjs style api usage 2024-10-24 11:54:22 +02:00
SwiftyOS
d5d9ecd71c Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components 2024-10-23 15:00:14 +02:00
SwiftyOS
52119eadc2 Update auth flow and added localisation 2024-10-23 12:48:03 +02:00
SwiftyOS
334b4d5ef9 Added correct redirects for auth login 2024-10-23 10:54:53 +02:00
SwiftyOS
d7fc2dfb46 Added dispaly name 2024-10-22 14:55:19 +02:00
SwiftyOS
120469c8bf verticaly centered the icon and text in popout menu items 2024-10-22 14:05:32 +02:00
SwiftyOS
259d6f2b69 Add login / logout functionality to navbar 2024-10-22 13:55:43 +02:00
Swifty
dce436fb30 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-10-22 11:24:05 +02:00
SwiftyOS
44cb4e8e77 Add Creator Dashboard 2024-10-22 11:14:06 +02:00
SwiftyOS
88767a84d1 Add Agent table 2024-10-22 10:57:11 +02:00
SwiftyOS
8b03477d2d addded sidebar 2024-10-21 11:40:22 +02:00
SwiftyOS
10a2b36dc9 fix accessability 2024-10-21 11:19:51 +02:00
SwiftyOS
b1ccdacd98 cleaned up code 2024-10-21 11:03:37 +02:00
SwiftyOS
8811011286 Organise Stories 2024-10-21 09:34:59 +02:00
Swifty
b8c764ad70 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-10-21 09:26:28 +02:00
Swifty
4d69b2eb75 Merge branch 'dev' into swiftyos/open-1920-marketplace-home-components 2024-10-17 17:12:05 +02:00
SwiftyOS
5adc6c0a46 more pages wip 2024-10-17 17:01:46 +02:00
SwiftyOS
915b08d8a7 agent page 2024-10-17 16:43:26 +02:00
SwiftyOS
2997b12367 fix stories 2024-10-17 16:21:28 +02:00
SwiftyOS
82f0ee2240 fixinb avatar sizes 2024-10-17 16:20:05 +02:00
SwiftyOS
26bef8b918 wip 2024-10-17 16:14:40 +02:00
SwiftyOS
82f553ec0d finalsing home page 2024-10-17 12:58:59 +02:00
SwiftyOS
4ea85b5eaf finalised store page 2024-10-17 12:47:25 +02:00
SwiftyOS
45efe5a947 responsive tweaks 2024-10-17 12:42:05 +02:00
SwiftyOS
c2b320dd6a Updated home page 2024-10-17 12:32:00 +02:00
SwiftyOS
6b2d264414 fixed responsive issues 2024-10-17 12:31:50 +02:00
SwiftyOS
9a742cbe93 Added navbar 2024-10-17 12:31:28 +02:00
SwiftyOS
f96f2f101b Mobile Menu and accessability 2024-10-17 12:25:12 +02:00
SwiftyOS
6318a976b5 Added popout menu 2024-10-17 12:24:14 +02:00
SwiftyOS
c2c39a0cd6 updated icons 2024-10-17 12:23:53 +02:00
SwiftyOS
657e64d903 delete storybook publish ci 2024-10-16 15:15:32 +02:00
SwiftyOS
a2e681a09f fix tests 2024-10-16 14:59:23 +02:00
SwiftyOS
1fc3b2aa0a updated yarn lock 2024-10-16 12:39:55 +02:00
SwiftyOS
5465296ba6 updated workflow 2024-10-16 12:31:37 +02:00
SwiftyOS
fbfb8838fd added publishing of storybooks to chromatic 2024-10-16 12:29:13 +02:00
SwiftyOS
130261c75f removed chromatic-com visual testing to get rid of punycode bug 2024-10-16 12:18:38 +02:00
SwiftyOS
c8c954d862 npm audit updates 2024-10-16 10:29:19 +02:00
SwiftyOS
abfa707f69 updated page stories to have a wider selection of images 2024-10-16 09:57:10 +02:00
SwiftyOS
a01f326f7e added boring avatars 2024-10-16 09:56:50 +02:00
SwiftyOS
e377711c7e Added seperator 2024-10-16 09:34:16 +02:00
SwiftyOS
4c40b5f187 Update page to make it responsive 2024-10-15 13:47:56 +02:00
SwiftyOS
a8c5264e17 more responsive design work 2024-10-15 13:17:35 +02:00
SwiftyOS
9e8f76b749 changed featured card to carousel when mobile view 2024-10-15 12:15:25 +02:00
SwiftyOS
43f8ed55ea made features responsive 2024-10-15 11:25:20 +02:00
SwiftyOS
5d5f14c799 made responsive 2024-10-15 11:08:35 +02:00
SwiftyOS
9db01a7836 Made become a creator responsive 2024-10-15 11:04:07 +02:00
SwiftyOS
1e4a96883f Made creator card responsive and included image 2024-10-15 10:55:47 +02:00
SwiftyOS
a34dc25b34 formatting 2024-10-15 10:45:12 +02:00
SwiftyOS
90d3954e8c made featured store card responsive 2024-10-15 10:44:53 +02:00
SwiftyOS
aeb43b7d37 made filter chips responsive 2024-10-15 09:56:01 +02:00
SwiftyOS
7dedcaddb6 made breadcrumbs responsive 2024-10-15 09:49:58 +02:00
SwiftyOS
02463a5cb2 responsified the searchbar 2024-10-14 15:36:51 +02:00
SwiftyOS
4c18763e55 Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-10-14 13:15:34 +02:00
SwiftyOS
8a9a1b59a4 agent infor 2024-10-14 13:15:30 +02:00
SwiftyOS
8d9b282376 agent images 2024-10-14 13:15:22 +02:00
SwiftyOS
ffdc457dea Breadcrumbs 2024-10-14 13:15:07 +02:00
SwiftyOS
015ac85a83 formatting 2024-10-14 13:14:58 +02:00
SwiftyOS
ee01a602ff responsive styling 2024-10-14 13:14:36 +02:00
SwiftyOS
f7f4207902 ui updates 2024-10-14 11:32:19 +02:00
Swifty
37bae48bd9 Merge branch 'master' into swiftyos/open-1920-marketplace-home-components 2024-10-14 10:01:23 +02:00
SwiftyOS
2ef21f929f added custom spacing and colors 2024-10-11 18:22:45 +02:00
SwiftyOS
6b77d71b88 formatting 2024-10-11 12:55:48 +02:00
SwiftyOS
e2946e0cd1 added navbar 2024-10-11 12:46:36 +02:00
SwiftyOS
887e7a4f0a added creator card 2024-10-11 12:46:22 +02:00
SwiftyOS
c4a8ab8a19 tweaked so spacing is added below the decsiption if the descriptiion is short 2024-10-11 12:31:56 +02:00
SwiftyOS
a3dda5d5a2 formatting 2024-10-11 12:26:02 +02:00
SwiftyOS
948279a67d Added filter chips 2024-10-11 12:25:49 +02:00
SwiftyOS
cf97e25b4a adding custom css styles 2024-10-11 12:25:17 +02:00
SwiftyOS
72e4eb2418 added font-neue 2024-10-11 12:05:28 +02:00
SwiftyOS
e241a4cc2a Added become a creator component 2024-10-11 12:01:48 +02:00
SwiftyOS
3accc65d44 Merge branch 'swiftyos/open-1920-marketplace-home-components' of github.com:Significant-Gravitas/AutoGPT into swiftyos/open-1920-marketplace-home-components 2024-10-11 11:51:26 +02:00
SwiftyOS
fd6f23f4a6 update styling 2024-10-11 11:51:20 +02:00
SwiftyOS
5ad56c553f Add avatar to the store card 2024-10-11 11:51:06 +02:00
Swifty
81471d8ffe Merge branch 'master' into swiftyos/open-1920-marketplace-home-components 2024-10-11 11:31:57 +02:00
SwiftyOS
e46bf6300e Added button 2024-10-11 11:31:39 +02:00
SwiftyOS
a0e87867b7 Added featured store card 2024-10-11 11:31:26 +02:00
SwiftyOS
00d5d843a2 Add store cards 2024-10-11 10:00:40 +02:00
SwiftyOS
3ce7cf2713 added search bar 2024-10-10 18:40:39 +02:00
175 changed files with 19539 additions and 4794 deletions

2
.gitignore vendored
View File

@@ -173,3 +173,5 @@ LICENSE.rtf
autogpt_platform/backend/settings.py
/.auth
/autogpt_platform/frontend/.auth
*.ign.*

View File

@@ -35,3 +35,12 @@ def verify_user(payload: dict | None, admin_only: bool) -> User:
raise fastapi.HTTPException(status_code=403, detail="Admin access required")
return User.from_payload(payload)
def get_user_id(payload: dict = fastapi.Depends(auth_middleware)) -> str:
user_id = payload.get("sub")
if not user_id:
raise fastapi.HTTPException(
status_code=401, detail="User ID not found in token"
)
return user_id

View File

@@ -16,6 +16,7 @@ import backend.data.db
import backend.data.graph
import backend.data.user
import backend.server.routers.v1
import backend.server.v2.store.routes
import backend.util.service
import backend.util.settings
@@ -73,7 +74,10 @@ def handle_internal_http_error(status_code: int = 500, log_error: bool = True):
app.add_exception_handler(ValueError, handle_internal_http_error(400))
app.add_exception_handler(Exception, handle_internal_http_error(500))
app.include_router(backend.server.routers.v1.v1_router, tags=["v1"])
app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/api")
app.include_router(
backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store"
)
@app.get(path="/health", tags=["health"], dependencies=[])

View File

@@ -69,8 +69,7 @@ integration_creds_manager = IntegrationCredentialsManager()
_user_credit_model = get_user_credit_model()
# Define the API routes
v1_router = APIRouter(prefix="/api")
v1_router = APIRouter()
v1_router.include_router(
backend.server.integrations.router.router,
@@ -132,7 +131,7 @@ def execute_graph_block(block_id: str, data: BlockInput) -> CompletedBlockOutput
@v1_router.get(path="/credits", dependencies=[Depends(auth_middleware)])
async def get_user_credits(
user_id: Annotated[str, Depends(get_user_id)]
user_id: Annotated[str, Depends(get_user_id)],
) -> dict[str, int]:
# Credits can go negative, so ensure it's at least 0 for user to see.
return {"credits": max(await _user_credit_model.get_or_refill_credit(user_id), 0)}

View File

@@ -0,0 +1,668 @@
import logging
from datetime import datetime
import prisma.enums
import prisma.errors
import prisma.models
import prisma.types
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
async def get_store_agents(
featured: bool = False,
creator: str | None = None,
sorted_by: str | None = None,
search_query: str | None = None,
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
logger.debug(
f"Getting store agents. featured={featured}, creator={creator}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
)
where_clause = {}
if featured:
where_clause["featured"] = featured
if creator:
where_clause["creator_username"] = creator
if category:
where_clause["categories"] = {"has": category}
if search_query:
where_clause["OR"] = [
{"agent_name": {"contains": search_query, "mode": "insensitive"}},
{"description": {"contains": search_query, "mode": "insensitive"}},
]
order_by = []
if sorted_by == "rating":
order_by.append({"rating": "desc"})
elif sorted_by == "runs":
order_by.append({"runs": "desc"})
elif sorted_by == "name":
order_by.append({"agent_name": "asc"})
try:
agents = await prisma.models.StoreAgent.prisma().find_many(
where=prisma.types.StoreAgentWhereInput(**where_clause),
order=order_by,
skip=(page - 1) * page_size,
take=page_size,
)
total = await prisma.models.StoreAgent.prisma().count(
where=prisma.types.StoreAgentWhereInput(**where_clause)
)
total_pages = (total + page_size - 1) // page_size
store_agents = [
backend.server.v2.store.model.StoreAgent(
slug=agent.slug,
agent_name=agent.agent_name,
agent_image=agent.agent_image[0] if agent.agent_image else "",
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
sub_heading=agent.sub_heading,
description=agent.description,
runs=agent.runs,
rating=agent.rating,
)
for agent in agents
]
logger.debug(f"Found {len(store_agents)} agents")
return backend.server.v2.store.model.StoreAgentsResponse(
agents=store_agents,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error getting store agents: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch store agents"
) from e
async def get_store_agent_details(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
logger.debug(f"Getting store agent details for {username}/{agent_name}")
try:
agent = await prisma.models.StoreAgent.prisma().find_first(
where={"creator_username": username, "slug": agent_name}
)
if not agent:
logger.warning(f"Agent not found: {username}/{agent_name}")
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent {username}/{agent_name} not found"
)
logger.debug(f"Found agent details for {username}/{agent_name}")
return backend.server.v2.store.model.StoreAgentDetails(
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_image=agent.agent_image,
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
sub_heading=agent.sub_heading,
description=agent.description,
categories=agent.categories,
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
last_updated=agent.updated_at,
)
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store agent details: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent details"
) from e
async def get_store_creators(
featured: bool = False,
search_query: str | None = None,
sorted_by: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
logger.debug(
f"Getting store creators. featured={featured}, search={search_query}, sorted_by={sorted_by}, page={page}"
)
# Build where clause
where = {}
# Add search filter if provided
if search_query:
where["OR"] = [
{"username": {"contains": search_query, "mode": "insensitive"}},
{"name": {"contains": search_query, "mode": "insensitive"}},
{"description": {"contains": search_query, "mode": "insensitive"}},
]
try:
# Get total count for pagination
total = await prisma.models.Creator.prisma().count(
where=prisma.types.CreatorWhereInput(**where)
)
total_pages = (total + page_size - 1) // page_size
# Add pagination
skip = (page - 1) * page_size
take = page_size
# Add sorting
order = []
if sorted_by == "agent_rating":
order.append({"agent_rating": "desc"})
elif sorted_by == "agent_runs":
order.append({"agent_runs": "desc"})
elif sorted_by == "num_agents":
order.append({"num_agents": "desc"})
else:
order.append({"username": "asc"})
# Execute query
creators = await prisma.models.Creator.prisma().find_many(
where=prisma.types.CreatorWhereInput(**where),
skip=skip,
take=take,
order=order,
)
# Convert to response model
creator_models = [
backend.server.v2.store.model.Creator(
username=creator.username,
name=creator.name,
description=creator.description,
avatar_url=creator.avatar_url,
num_agents=creator.num_agents,
agent_rating=creator.agent_rating,
agent_runs=creator.agent_runs,
)
for creator in creators
]
logger.debug(f"Found {len(creator_models)} creators")
return backend.server.v2.store.model.CreatorsResponse(
creators=creator_models,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error getting store creators: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch store creators"
) from e
async def get_store_creator_details(
username: str,
) -> backend.server.v2.store.model.CreatorDetails:
logger.debug(f"Getting store creator details for {username}")
try:
# Query creator details from database
creator = await prisma.models.Creator.prisma().find_unique(
where={"username": username}
)
if not creator:
logger.warning(f"Creator not found: {username}")
raise backend.server.v2.store.exceptions.CreatorNotFoundError(
f"Creator {username} not found"
)
logger.debug(f"Found creator details for {username}")
return backend.server.v2.store.model.CreatorDetails(
name=creator.name,
username=creator.username,
description=creator.description,
links=creator.links,
avatar_url=creator.avatar_url,
agent_rating=creator.agent_rating,
agent_runs=creator.agent_runs,
top_categories=creator.top_categories,
)
except backend.server.v2.store.exceptions.CreatorNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store creator details: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch creator details"
) from e
async def get_store_submissions(
user_id: str, page: int = 1, page_size: int = 20
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
logger.debug(f"Getting store submissions for user {user_id}, page={page}")
try:
# Calculate pagination values
skip = (page - 1) * page_size
where = prisma.types.StoreSubmissionWhereInput(user_id=user_id)
# Query submissions from database
submissions = await prisma.models.StoreSubmission.prisma().find_many(
where=where, skip=skip, take=page_size, order=[{"date_submitted": "desc"}]
)
# Get total count for pagination
total = await prisma.models.StoreSubmission.prisma().count(where=where)
total_pages = (total + page_size - 1) // page_size
# Convert to response models
submission_models = [
backend.server.v2.store.model.StoreSubmission(
agent_id=sub.agent_id,
agent_version=sub.agent_version,
name=sub.name,
sub_heading=sub.sub_heading,
slug=sub.slug,
description=sub.description,
image_urls=sub.image_urls or [],
date_submitted=sub.date_submitted or datetime.now(),
status=sub.status,
runs=sub.runs or 0,
rating=sub.rating or 0.0,
)
for sub in submissions
]
logger.debug(f"Found {len(submission_models)} submissions")
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=submission_models,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error fetching store submissions: {str(e)}")
# Return empty response rather than exposing internal errors
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=0,
total_pages=0,
page_size=page_size,
),
)
async def delete_store_submission(
user_id: str,
submission_id: str,
) -> bool:
"""
Delete a store listing submission.
Args:
user_id: ID of the authenticated user
submission_id: ID of the submission to be deleted
Returns:
bool: True if the submission was successfully deleted, False otherwise
"""
logger.debug(f"Deleting store submission {submission_id} for user {user_id}")
try:
# Verify the submission belongs to this user
submission = await prisma.models.StoreListing.prisma().find_first(
where={"agentId": submission_id, "owningUserId": user_id}
)
if not submission:
logger.warning(f"Submission not found for user {user_id}: {submission_id}")
raise backend.server.v2.store.exceptions.SubmissionNotFoundError(
f"Submission not found for this user. User ID: {user_id}, Submission ID: {submission_id}"
)
# Delete the submission
await prisma.models.StoreListing.prisma().delete(
where=prisma.types.StoreListingWhereUniqueInput(id=submission.id)
)
logger.debug(
f"Successfully deleted submission {submission_id} for user {user_id}"
)
return True
except Exception as e:
logger.error(f"Error deleting store submission: {str(e)}")
return False
async def create_store_submission(
user_id: str,
agent_id: str,
agent_version: int,
slug: str,
name: str,
video_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new store listing submission.
Args:
user_id: ID of the authenticated user submitting the listing
agent_id: ID of the agent being submitted
agent_version: Version of the agent being submitted
slug: URL slug for the listing
name: Name of the agent
video_url: Optional URL to video demo
image_urls: List of image URLs for the listing
description: Description of the agent
categories: List of categories for the agent
Returns:
StoreSubmission: The created store submission
"""
logger.debug(
f"Creating store submission for user {user_id}, agent {agent_id} v{agent_version}"
)
try:
# First verify the agent belongs to this user
agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput(
id=agent_id, version=agent_version, userId=user_id
)
)
if not agent:
logger.warning(
f"Agent not found for user {user_id}: {agent_id} v{agent_version}"
)
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
agentId=agent_id, owningUserId=user_id
)
)
if listing is not None:
logger.warning(f"Listing already exists for agent {agent_id}")
raise backend.server.v2.store.exceptions.ListingExistsError(
"Listing already exists for this agent"
)
# Create the store listing
listing = await prisma.models.StoreListing.prisma().create(
data={
"agentId": agent_id,
"agentVersion": agent_version,
"owningUserId": user_id,
"createdAt": datetime.now(),
"StoreListingVersions": {
"create": {
"agentId": agent_id,
"agentVersion": agent_version,
"slug": slug,
"name": name,
"videoUrl": video_url,
"imageUrls": image_urls,
"description": description,
"categories": categories,
"subHeading": sub_heading,
}
},
}
)
logger.debug(f"Created store listing for agent {agent_id}")
# Return submission details
return backend.server.v2.store.model.StoreSubmission(
agent_id=agent_id,
agent_version=agent_version,
name=name,
slug=slug,
sub_heading=sub_heading,
description=description,
image_urls=image_urls,
date_submitted=listing.createdAt,
status=prisma.enums.SubmissionStatus.PENDING,
runs=0,
rating=0.0,
)
except (
backend.server.v2.store.exceptions.AgentNotFoundError,
backend.server.v2.store.exceptions.ListingExistsError,
):
raise
except prisma.errors.PrismaError as e:
logger.error(f"Database error creating store submission: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store submission"
) from e
async def get_user_profile(
user_id: str,
) -> backend.server.v2.store.model.ProfileDetails:
logger.debug(f"Getting user profile for {user_id}")
try:
profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id} # type: ignore
)
if not profile:
logger.warning(f"Profile not found for user {user_id}")
await prisma.models.Profile.prisma().create(
data=prisma.types.ProfileCreateInput(
userId=user_id,
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatarUrl="",
)
)
return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatar_url="",
)
return backend.server.v2.store.model.ProfileDetails(
name=profile.name,
username=profile.username,
description=profile.description,
links=profile.links,
avatar_url=profile.avatarUrl,
)
except Exception as e:
logger.error(f"Error getting user profile: {str(e)}")
return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatar_url="",
)
async def update_or_create_profile(
user_id: str, profile: backend.server.v2.store.model.Profile
) -> backend.server.v2.store.model.CreatorDetails:
"""
Update the store profile for a user. Creates a new profile if one doesn't exist.
Only allows updating if the user_id matches the owning user.
Args:
user_id: ID of the authenticated user
profile: Updated profile details
Returns:
CreatorDetails: The updated profile
Raises:
HTTPException: If user is not authorized to update this profile
"""
logger.debug(f"Updating profile for user {user_id}")
try:
# Check if profile exists for user
existing_profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id}
)
# If no profile exists, create a new one
if not existing_profile:
logger.debug(f"Creating new profile for user {user_id}")
# Create new profile since one doesn't exist
new_profile = await prisma.models.Profile.prisma().create(
data={
"userId": user_id,
"name": profile.name,
"username": profile.username,
"description": profile.description,
"links": profile.links,
"avatarUrl": profile.avatar_url,
}
)
return backend.server.v2.store.model.CreatorDetails(
name=new_profile.name,
username=new_profile.username,
description=new_profile.description,
links=new_profile.links,
avatar_url=new_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
)
else:
logger.debug(f"Updating existing profile for user {user_id}")
# Update the existing profile
updated_profile = await prisma.models.Profile.prisma().update(
where={"id": existing_profile.id},
data=prisma.types.ProfileUpdateInput(
name=profile.name,
username=profile.username,
description=profile.description,
links=profile.links,
avatarUrl=profile.avatar_url,
),
)
if updated_profile is None:
logger.error(f"Failed to update profile for user {user_id}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
)
return backend.server.v2.store.model.CreatorDetails(
name=updated_profile.name,
username=updated_profile.username,
description=updated_profile.description,
links=updated_profile.links,
avatar_url=updated_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
)
except prisma.errors.PrismaError as e:
logger.error(f"Database error updating profile: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
) from e
async def get_my_agents(
user_id: str,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.MyAgentsResponse:
logger.debug(f"Getting my agents for user {user_id}, page={page}")
try:
agents_with_max_version = await prisma.models.AgentGraph.prisma().find_many(
where=prisma.types.AgentGraphWhereInput(
userId=user_id, StoreListing={"none": {"isDeleted": False}}
),
order=[{"version": "desc"}],
distinct=["id"],
skip=(page - 1) * page_size,
take=page_size,
)
# store_listings = await prisma.models.StoreListing.prisma().find_many(
# where=prisma.types.StoreListingWhereInput(
# isDeleted=False,
# ),
# )
total = len(
await prisma.models.AgentGraph.prisma().find_many(
where=prisma.types.AgentGraphWhereInput(
userId=user_id, StoreListing={"none": {"isDeleted": False}}
),
order=[{"version": "desc"}],
distinct=["id"],
)
)
total_pages = (total + page_size - 1) // page_size
agents = agents_with_max_version
my_agents = [
backend.server.v2.store.model.MyAgent(
agent_id=agent.id,
agent_version=agent.version,
agent_name=agent.name or "",
last_edited=agent.updatedAt or agent.createdAt,
)
for agent in agents
]
return backend.server.v2.store.model.MyAgentsResponse(
agents=my_agents,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error getting my agents: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch my agents"
) from e

View File

@@ -0,0 +1,282 @@
from datetime import datetime
import prisma.errors
import prisma.models
import pytest
from prisma import Prisma, register
import backend.server.v2.store.db as db
import backend.server.v2.store.exceptions
from backend.server.v2.store.model import CreatorDetails
@pytest.fixture(autouse=True)
async def setup_prisma():
try:
register(Prisma())
except backend.server.v2.store.exceptions.DatabaseError:
pass
yield
@pytest.mark.asyncio
async def test_get_store_agents(mocker):
# Mock data
mock_agents = [
prisma.models.StoreAgent(
listing_id="test-id",
slug="test-agent",
agent_name="Test Agent",
agent_video=None,
agent_image=["image.jpg"],
featured=False,
creator_username="creator",
creator_avatar="avatar.jpg",
sub_heading="Test heading",
description="Test description",
categories=[],
runs=10,
rating=4.5,
versions=["1.0"],
updated_at=datetime.now(),
)
]
# Mock prisma calls
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_many = mocker.AsyncMock(return_value=mock_agents)
mock_store_agent.return_value.count = mocker.AsyncMock(return_value=1)
# Call function
result = await db.get_store_agents()
# Verify results
assert len(result.agents) == 1
assert result.agents[0].slug == "test-agent"
assert result.pagination.total_items == 1
# Verify mocks called correctly
mock_store_agent.return_value.find_many.assert_called_once()
mock_store_agent.return_value.count.assert_called_once()
@pytest.mark.asyncio
async def test_get_store_agent_details(mocker):
# Mock data
mock_agent = prisma.models.StoreAgent(
listing_id="test-id",
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_image=["image.jpg"],
featured=False,
creator_username="creator",
creator_avatar="avatar.jpg",
sub_heading="Test heading",
description="Test description",
categories=["test"],
runs=10,
rating=4.5,
versions=["1.0"],
updated_at=datetime.now(),
)
# Mock prisma call
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
# Call function
result = await db.get_store_agent_details("creator", "test-agent")
# Verify results
assert result.slug == "test-agent"
assert result.agent_name == "Test Agent"
# Verify mock called correctly
mock_store_agent.return_value.find_first.assert_called_once_with(
where={"creator_username": "creator", "slug": "test-agent"}
)
@pytest.mark.asyncio
async def test_get_store_creator_details(mocker):
# Mock data
mock_creator_data = prisma.models.Creator(
name="Test Creator",
username="creator",
description="Test description",
links=["link1"],
avatar_url="avatar.jpg",
num_agents=1,
agent_rating=4.5,
agent_runs=10,
top_categories=["test"],
)
# Mock prisma call
mock_creator = mocker.patch("prisma.models.Creator.prisma")
mock_creator.return_value.find_unique = mocker.AsyncMock()
# Configure the mock to return values that will pass validation
mock_creator.return_value.find_unique.return_value = mock_creator_data
# Call function
result = await db.get_store_creator_details("creator")
# Verify results
assert result.username == "creator"
assert result.name == "Test Creator"
assert result.description == "Test description"
assert result.avatar_url == "avatar.jpg"
# Verify mock called correctly
mock_creator.return_value.find_unique.assert_called_once_with(
where={"username": "creator"}
)
@pytest.mark.asyncio
async def test_create_store_submission(mocker):
# Mock data
mock_agent = prisma.models.AgentGraph(
id="agent-id",
version=1,
userId="user-id",
createdAt=datetime.now(),
isActive=True,
isTemplate=False,
)
mock_listing = prisma.models.StoreListing(
id="listing-id",
createdAt=datetime.now(),
updatedAt=datetime.now(),
isDeleted=False,
isApproved=False,
agentId="agent-id",
agentVersion=1,
owningUserId="user-id",
)
# Mock prisma calls
mock_agent_graph = mocker.patch("prisma.models.AgentGraph.prisma")
mock_agent_graph.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
mock_store_listing = mocker.patch("prisma.models.StoreListing.prisma")
mock_store_listing.return_value.find_first = mocker.AsyncMock(return_value=None)
mock_store_listing.return_value.create = mocker.AsyncMock(return_value=mock_listing)
# Call function
result = await db.create_store_submission(
user_id="user-id",
agent_id="agent-id",
agent_version=1,
slug="test-agent",
name="Test Agent",
description="Test description",
)
# Verify results
assert result.name == "Test Agent"
assert result.description == "Test description"
# Verify mocks called correctly
mock_agent_graph.return_value.find_first.assert_called_once()
mock_store_listing.return_value.find_first.assert_called_once()
mock_store_listing.return_value.create.assert_called_once()
@pytest.mark.asyncio
async def test_update_profile(mocker):
# Mock data
mock_profile = prisma.models.Profile(
id="profile-id",
name="Test Creator",
username="creator",
description="Test description",
links=["link1"],
avatarUrl="avatar.jpg",
createdAt=datetime.now(),
updatedAt=datetime.now(),
)
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_first = mocker.AsyncMock(
return_value=mock_profile
)
mock_profile_db.return_value.update = mocker.AsyncMock(return_value=mock_profile)
# Test data
profile = CreatorDetails(
name="Test Creator",
username="creator",
description="Test description",
links=["link1"],
avatar_url="avatar.jpg",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
)
# Call function
result = await db.update_or_create_profile("user-id", profile)
# Verify results
assert result.username == "creator"
assert result.name == "Test Creator"
# Verify mocks called correctly
mock_profile_db.return_value.find_first.assert_called_once()
mock_profile_db.return_value.update.assert_called_once()
@pytest.mark.asyncio
async def test_get_user_profile(mocker):
# Mock data
mock_profile = prisma.models.Profile(
id="profile-id",
name="Test User",
username="testuser",
description="Test description",
links=["link1", "link2"],
avatarUrl="avatar.jpg",
createdAt=datetime.now(),
updatedAt=datetime.now(),
)
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_unique = mocker.AsyncMock(
return_value=mock_profile
)
# Call function
result = await db.get_user_profile("user-id")
# Verify results
assert result.name == "Test User"
assert result.username == "testuser"
assert result.description == "Test description"
assert result.links == ["link1", "link2"]
assert result.avatar_url == "avatar.jpg"
# Verify mock called correctly
mock_profile_db.return_value.find_unique.assert_called_once_with(
where={"userId": "user-id"}
)
@pytest.mark.asyncio
async def test_get_user_profile_not_found(mocker):
# Mock prisma calls to return None
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_unique = mocker.AsyncMock(return_value=None)
# Verify exception raised
with pytest.raises(backend.server.v2.store.exceptions.ProfileNotFoundError):
await db.get_user_profile("user-id")
# Verify mock called correctly
mock_profile_db.return_value.find_unique.assert_called_once_with(
where={"userId": "user-id"}
)

View File

@@ -0,0 +1,76 @@
class MediaUploadError(Exception):
"""Base exception for media upload errors"""
pass
class InvalidFileTypeError(MediaUploadError):
"""Raised when file type is not supported"""
pass
class FileSizeTooLargeError(MediaUploadError):
"""Raised when file size exceeds maximum limit"""
pass
class FileReadError(MediaUploadError):
"""Raised when there's an error reading the file"""
pass
class StorageConfigError(MediaUploadError):
"""Raised when storage configuration is invalid"""
pass
class StorageUploadError(MediaUploadError):
"""Raised when upload to storage fails"""
pass
class StoreError(Exception):
"""Base exception for store-related errors"""
pass
class AgentNotFoundError(StoreError):
"""Raised when an agent is not found"""
pass
class CreatorNotFoundError(StoreError):
"""Raised when a creator is not found"""
pass
class ListingExistsError(StoreError):
"""Raised when trying to create a listing that already exists"""
pass
class DatabaseError(StoreError):
"""Raised when there is an error interacting with the database"""
pass
class ProfileNotFoundError(StoreError):
"""Raised when a profile is not found"""
pass
class SubmissionNotFoundError(StoreError):
"""Raised when a submission is not found"""
pass

View File

@@ -0,0 +1,98 @@
import logging
import os
import uuid
import dotenv
import fastapi
from google.cloud import storage
import backend.server.v2.store.exceptions
dotenv.load_dotenv()
logger = logging.getLogger(__name__)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
# Check required environment variables first before doing any file processing
if not os.environ.get("MEDIA_GCS_BUCKET_NAME"):
logger.error("Missing required GCS environment variables")
raise backend.server.v2.store.exceptions.StorageConfigError(
"Missing storage configuration"
)
try:
# Validate file type
content_type = file.content_type
if (
content_type not in ALLOWED_IMAGE_TYPES
and content_type not in ALLOWED_VIDEO_TYPES
):
logger.warning(f"Invalid file type attempted: {content_type}")
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
f"File type not supported. Must be jpeg, png, gif, webp, mp4 or webm. Content type: {content_type}"
)
# Validate file size
file_size = 0
chunk_size = 8192 # 8KB chunks
try:
while chunk := await file.read(chunk_size):
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
logger.warning(f"File size too large: {file_size} bytes")
raise backend.server.v2.store.exceptions.FileSizeTooLargeError(
"File too large. Maximum size is 50MB"
)
except backend.server.v2.store.exceptions.FileSizeTooLargeError:
raise
except Exception as e:
logger.error(f"Error reading file chunks: {str(e)}")
raise backend.server.v2.store.exceptions.FileReadError(
"Failed to read uploaded file"
) from e
# Reset file pointer
await file.seek(0)
# Generate unique filename
filename = file.filename or ""
file_ext = os.path.splitext(filename)[1].lower()
unique_filename = f"{uuid.uuid4()}{file_ext}"
# Construct storage path
media_type = "images" if content_type in ALLOWED_IMAGE_TYPES else "videos"
storage_path = f"users/{user_id}/{media_type}/{unique_filename}"
try:
storage_client = storage.Client()
bucket = storage_client.bucket(os.environ["MEDIA_GCS_BUCKET_NAME"])
blob = bucket.blob(storage_path)
blob.content_type = content_type
file_bytes = await file.read()
blob.upload_from_string(file_bytes, content_type=content_type)
public_url = blob.public_url
logger.info(f"Successfully uploaded file to: {storage_path}")
return public_url
except Exception as e:
logger.error(f"GCS storage error: {str(e)}")
raise backend.server.v2.store.exceptions.StorageUploadError(
"Failed to upload file to storage"
) from e
except backend.server.v2.store.exceptions.MediaUploadError:
raise
except Exception as e:
logger.exception("Unexpected error in upload_media")
raise backend.server.v2.store.exceptions.MediaUploadError(
"Unexpected error during media upload"
) from e

View File

@@ -0,0 +1,97 @@
import io
import unittest.mock
import fastapi
import pytest
import starlette.datastructures
import backend.server.v2.store.exceptions
import backend.server.v2.store.media
@pytest.fixture
def mock_env_vars(monkeypatch):
monkeypatch.setenv("GCS_BUCKET_NAME", "test-bucket")
@pytest.fixture
def mock_storage_client(mocker):
mock_client = unittest.mock.MagicMock()
mock_bucket = unittest.mock.MagicMock()
mock_blob = unittest.mock.MagicMock()
mock_client.bucket.return_value = mock_bucket
mock_bucket.blob.return_value = mock_blob
mock_blob.public_url = "http://test-url/media/test.jpg"
mocker.patch("google.cloud.storage.Client", return_value=mock_client)
return mock_client
async def test_upload_media_success(mock_env_vars, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.jpeg",
file=io.BytesIO(b"test data"),
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/test.jpg"
mock_bucket = mock_storage_client.bucket.return_value
mock_blob = mock_bucket.blob.return_value
mock_blob.upload_from_string.assert_called_once()
async def test_upload_media_invalid_type(mock_env_vars, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.txt",
file=io.BytesIO(b"test data"),
headers=starlette.datastructures.Headers({"content-type": "text/plain"}),
)
with pytest.raises(backend.server.v2.store.exceptions.InvalidFileTypeError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
mock_bucket = mock_storage_client.bucket.return_value
mock_blob = mock_bucket.blob.return_value
mock_blob.upload_from_string.assert_not_called()
async def test_upload_media_missing_credentials():
test_file = fastapi.UploadFile(
filename="test.jpeg",
file=io.BytesIO(b"test data"),
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
with pytest.raises(backend.server.v2.store.exceptions.StorageConfigError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
async def test_upload_media_video_type(mock_env_vars, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.mp4",
file=io.BytesIO(b"test video data"),
headers=starlette.datastructures.Headers({"content-type": "video/mp4"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/test.jpg"
mock_bucket = mock_storage_client.bucket.return_value
mock_blob = mock_bucket.blob.return_value
mock_blob.upload_from_string.assert_called_once()
async def test_upload_media_file_too_large(mock_env_vars, mock_storage_client):
large_data = b"x" * (50 * 1024 * 1024 + 1) # 50MB + 1 byte
test_file = fastapi.UploadFile(
filename="test.jpeg",
file=io.BytesIO(large_data),
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
with pytest.raises(backend.server.v2.store.exceptions.FileSizeTooLargeError):
await backend.server.v2.store.media.upload_media("test-user", test_file)

View File

@@ -0,0 +1,138 @@
import datetime
from typing import List
import prisma.enums
import pydantic
class Pagination(pydantic.BaseModel):
total_items: int = pydantic.Field(
description="Total number of items.", examples=[42]
)
total_pages: int = pydantic.Field(
description="Total number of pages.", examples=[97]
)
current_page: int = pydantic.Field(
description="Current_page page number.", examples=[1]
)
page_size: int = pydantic.Field(
description="Number of items per page.", examples=[25]
)
class MyAgent(pydantic.BaseModel):
agent_id: str
agent_version: int
agent_name: str
last_edited: datetime.datetime
class MyAgentsResponse(pydantic.BaseModel):
agents: list[MyAgent]
pagination: Pagination
class StoreAgent(pydantic.BaseModel):
slug: str
agent_name: str
agent_image: str
creator: str
creator_avatar: str
sub_heading: str
description: str
runs: int
rating: float
class StoreAgentsResponse(pydantic.BaseModel):
agents: list[StoreAgent]
pagination: Pagination
class StoreAgentDetails(pydantic.BaseModel):
slug: str
agent_name: str
agent_video: str
agent_image: list[str]
creator: str
creator_avatar: str
sub_heading: str
description: str
categories: list[str]
runs: int
rating: float
versions: list[str]
last_updated: datetime.datetime
class Creator(pydantic.BaseModel):
name: str
username: str
description: str
avatar_url: str
num_agents: int
agent_rating: float
agent_runs: int
class CreatorsResponse(pydantic.BaseModel):
creators: List[Creator]
pagination: Pagination
class CreatorDetails(pydantic.BaseModel):
name: str
username: str
description: str
links: list[str]
avatar_url: str
agent_rating: float
agent_runs: int
top_categories: list[str]
class Profile(pydantic.BaseModel):
name: str
username: str
description: str
links: list[str]
avatar_url: str
class StoreSubmission(pydantic.BaseModel):
agent_id: str
agent_version: int
name: str
sub_heading: str
slug: str
description: str
image_urls: list[str]
date_submitted: datetime.datetime
status: prisma.enums.SubmissionStatus
runs: int
rating: float
class StoreSubmissionsResponse(pydantic.BaseModel):
submissions: list[StoreSubmission]
pagination: Pagination
class StoreSubmissionRequest(pydantic.BaseModel):
agent_id: str
agent_version: int
slug: str
name: str
sub_heading: str
video_url: str | None = None
image_urls: list[str] = []
description: str = ""
categories: list[str] = []
class ProfileDetails(pydantic.BaseModel):
name: str
username: str
description: str
links: list[str]
avatar_url: str | None = None

View File

@@ -0,0 +1,192 @@
import datetime
import prisma.enums
import backend.server.v2.store.model
def test_pagination():
pagination = backend.server.v2.store.model.Pagination(
total_items=100, total_pages=5, current_page=2, page_size=20
)
assert pagination.total_items == 100
assert pagination.total_pages == 5
assert pagination.current_page == 2
assert pagination.page_size == 20
def test_store_agent():
agent = backend.server.v2.store.model.StoreAgent(
slug="test-agent",
agent_name="Test Agent",
agent_image="test.jpg",
creator="creator1",
creator_avatar="avatar.jpg",
sub_heading="Test subheading",
description="Test description",
runs=50,
rating=4.5,
)
assert agent.slug == "test-agent"
assert agent.agent_name == "Test Agent"
assert agent.runs == 50
assert agent.rating == 4.5
def test_store_agents_response():
response = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="test-agent",
agent_name="Test Agent",
agent_image="test.jpg",
creator="creator1",
creator_avatar="avatar.jpg",
sub_heading="Test subheading",
description="Test description",
runs=50,
rating=4.5,
)
],
pagination=backend.server.v2.store.model.Pagination(
total_items=1, total_pages=1, current_page=1, page_size=20
),
)
assert len(response.agents) == 1
assert response.pagination.total_items == 1
def test_store_agent_details():
details = backend.server.v2.store.model.StoreAgentDetails(
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_image=["image1.jpg", "image2.jpg"],
creator="creator1",
creator_avatar="avatar.jpg",
sub_heading="Test subheading",
description="Test description",
categories=["cat1", "cat2"],
runs=50,
rating=4.5,
versions=["1.0", "2.0"],
last_updated=datetime.datetime.now(),
)
assert details.slug == "test-agent"
assert len(details.agent_image) == 2
assert len(details.categories) == 2
assert len(details.versions) == 2
def test_creator():
creator = backend.server.v2.store.model.Creator(
agent_rating=4.8,
agent_runs=1000,
name="Test Creator",
username="creator1",
description="Test description",
avatar_url="avatar.jpg",
num_agents=5,
)
assert creator.name == "Test Creator"
assert creator.num_agents == 5
def test_creators_response():
response = backend.server.v2.store.model.CreatorsResponse(
creators=[
backend.server.v2.store.model.Creator(
agent_rating=4.8,
agent_runs=1000,
name="Test Creator",
username="creator1",
description="Test description",
avatar_url="avatar.jpg",
num_agents=5,
)
],
pagination=backend.server.v2.store.model.Pagination(
total_items=1, total_pages=1, current_page=1, page_size=20
),
)
assert len(response.creators) == 1
assert response.pagination.total_items == 1
def test_creator_details():
details = backend.server.v2.store.model.CreatorDetails(
name="Test Creator",
username="creator1",
description="Test description",
links=["link1.com", "link2.com"],
avatar_url="avatar.jpg",
agent_rating=4.8,
agent_runs=1000,
top_categories=["cat1", "cat2"],
)
assert details.name == "Test Creator"
assert len(details.links) == 2
assert details.agent_rating == 4.8
assert len(details.top_categories) == 2
def test_store_submission():
submission = backend.server.v2.store.model.StoreSubmission(
agent_id="agent123",
agent_version=1,
sub_heading="Test subheading",
name="Test Agent",
slug="test-agent",
description="Test description",
image_urls=["image1.jpg", "image2.jpg"],
date_submitted=datetime.datetime(2023, 1, 1),
status=prisma.enums.SubmissionStatus.PENDING,
runs=50,
rating=4.5,
)
assert submission.name == "Test Agent"
assert len(submission.image_urls) == 2
assert submission.status == prisma.enums.SubmissionStatus.PENDING
def test_store_submissions_response():
response = backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[
backend.server.v2.store.model.StoreSubmission(
agent_id="agent123",
agent_version=1,
sub_heading="Test subheading",
name="Test Agent",
slug="test-agent",
description="Test description",
image_urls=["image1.jpg"],
date_submitted=datetime.datetime(2023, 1, 1),
status=prisma.enums.SubmissionStatus.PENDING,
runs=50,
rating=4.5,
)
],
pagination=backend.server.v2.store.model.Pagination(
total_items=1, total_pages=1, current_page=1, page_size=20
),
)
assert len(response.submissions) == 1
assert response.pagination.total_items == 1
def test_store_submission_request():
request = backend.server.v2.store.model.StoreSubmissionRequest(
agent_id="agent123",
agent_version=1,
slug="test-agent",
name="Test Agent",
sub_heading="Test subheading",
video_url="video.mp4",
image_urls=["image1.jpg", "image2.jpg"],
description="Test description",
categories=["cat1", "cat2"],
)
assert request.agent_id == "agent123"
assert request.agent_version == 1
assert len(request.image_urls) == 2
assert len(request.categories) == 2

View File

@@ -0,0 +1,399 @@
import logging
import typing
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.responses
import backend.server.v2.store.db
import backend.server.v2.store.media
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter()
##############################################
############### Profile Endpoints ############
##############################################
@router.get("/profile", tags=["store", "private"])
async def get_profile(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.ProfileDetails:
"""
Get the profile details for the authenticated user.
"""
try:
profile = await backend.server.v2.store.db.get_user_profile(user_id)
return profile
except Exception:
logger.exception("Exception occurred whilst getting user profile")
raise
@router.post(
"/profile",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def update_or_create_profile(
profile: backend.server.v2.store.model.Profile,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.CreatorDetails:
"""
Update the store profile for the authenticated user.
Args:
profile (Profile): The updated profile details
user_id (str): ID of the authenticated user
Returns:
CreatorDetails: The updated profile
Raises:
HTTPException: If there is an error updating the profile
"""
try:
updated_profile = await backend.server.v2.store.db.update_or_create_profile(
user_id=user_id, profile=profile
)
return updated_profile
except Exception:
logger.exception("Exception occurred whilst updating profile")
raise
##############################################
############### Agent Endpoints ##############
##############################################
@router.get("/agents", tags=["store", "public"])
async def get_agents(
featured: bool = False,
creator: str | None = None,
sorted_by: str | None = None,
search_query: str | None = None,
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
"""
Get a paginated list of agents from the store with optional filtering and sorting.
Args:
featured (bool, optional): Filter to only show featured agents. Defaults to False.
creator (str | None, optional): Filter agents by creator username. Defaults to None.
sorted_by (str | None, optional): Sort agents by "runs" or "rating". Defaults to None.
search_query (str | None, optional): Search agents by name, subheading and description. Defaults to None.
category (str | None, optional): Filter agents by category. Defaults to None.
page (int, optional): Page number for pagination. Defaults to 1.
page_size (int, optional): Number of agents per page. Defaults to 20.
Returns:
StoreAgentsResponse: Paginated list of agents matching the filters
Raises:
HTTPException: If page or page_size are less than 1
Used for:
- Home Page Featured Agents
- Home Page Top Agents
- Search Results
- Agent Details - Other Agents By Creator
- Agent Details - Similar Agents
- Creator Details - Agents By Creator
"""
if page < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page must be greater than 0"
)
if page_size < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page size must be greater than 0"
)
try:
agents = await backend.server.v2.store.db.get_store_agents(
featured=featured,
creator=creator,
sorted_by=sorted_by,
search_query=search_query,
category=category,
page=page,
page_size=page_size,
)
return agents
except Exception:
logger.exception("Exception occured whilst getting store agents")
raise
@router.get("/agents/{username}/{agent_name}", tags=["store", "public"])
async def get_agent(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
"""
This is only used on the AgentDetails Page
It returns the store listing agents details.
"""
try:
agent = await backend.server.v2.store.db.get_store_agent_details(
username=username, agent_name=agent_name
)
return agent
except Exception:
logger.exception("Exception occurred whilst getting store agent details")
raise
##############################################
############# Creator Endpoints #############
##############################################
@router.get("/creators", tags=["store", "public"])
async def get_creators(
featured: bool = False,
search_query: str | None = None,
sorted_by: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
"""
This is needed for:
- Home Page Featured Creators
- Search Results Page
---
To support this functionality we need:
- featured: bool - to limit the list to just featured agents
- search_query: str - vector search based on the creators profile description.
- sorted_by: [agent_rating, agent_runs] -
"""
if page < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page must be greater than 0"
)
if page_size < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page size must be greater than 0"
)
try:
creators = await backend.server.v2.store.db.get_store_creators(
featured=featured,
search_query=search_query,
sorted_by=sorted_by,
page=page,
page_size=page_size,
)
return creators
except Exception:
logger.exception("Exception occurred whilst getting store creators")
raise
@router.get("/creator/{username}", tags=["store", "public"])
async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDetails:
"""
Get the details of a creator
- Creator Details Page
"""
try:
creator = await backend.server.v2.store.db.get_store_creator_details(
username=username
)
return creator
except Exception:
logger.exception("Exception occurred whilst getting creator details")
raise
############################################
############# Store Submissions ###############
############################################
@router.get(
"/myagents",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def get_my_agents(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.MyAgentsResponse:
try:
agents = await backend.server.v2.store.db.get_my_agents(user_id)
return agents
except Exception:
logger.exception("Exception occurred whilst getting my agents")
raise
@router.delete(
"/submissions/{submission_id}",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def delete_submission(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
submission_id: str,
) -> bool:
"""
Delete a store listing submission.
Args:
user_id (str): ID of the authenticated user
submission_id (str): ID of the submission to be deleted
Returns:
bool: True if the submission was successfully deleted, False otherwise
"""
try:
result = await backend.server.v2.store.db.delete_store_submission(
user_id=user_id,
submission_id=submission_id,
)
return result
except Exception:
logger.exception("Exception occurred whilst deleting store submission")
raise
@router.get(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def get_submissions(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
"""
Get a paginated list of store submissions for the authenticated user.
Args:
user_id (str): ID of the authenticated user
page (int, optional): Page number for pagination. Defaults to 1.
page_size (int, optional): Number of submissions per page. Defaults to 20.
Returns:
StoreListingsResponse: Paginated list of store submissions
Raises:
HTTPException: If page or page_size are less than 1
"""
if page < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page must be greater than 0"
)
if page_size < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page size must be greater than 0"
)
try:
listings = await backend.server.v2.store.db.get_store_submissions(
user_id=user_id,
page=page,
page_size=page_size,
)
return listings
except Exception:
logger.exception("Exception occurred whilst getting store submissions")
raise
@router.post(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def create_submission(
submission_request: backend.server.v2.store.model.StoreSubmissionRequest,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new store listing submission.
Args:
submission_request (StoreSubmissionRequest): The submission details
user_id (str): ID of the authenticated user submitting the listing
Returns:
StoreSubmission: The created store submission
Raises:
HTTPException: If there is an error creating the submission
"""
try:
submission = await backend.server.v2.store.db.create_store_submission(
user_id=user_id,
agent_id=submission_request.agent_id,
agent_version=submission_request.agent_version,
slug=submission_request.slug,
name=submission_request.name,
video_url=submission_request.video_url,
image_urls=submission_request.image_urls,
description=submission_request.description,
sub_heading=submission_request.sub_heading,
categories=submission_request.categories,
)
return submission
except Exception:
logger.exception("Exception occurred whilst creating store submission")
raise
@router.post(
"/submissions/media",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def upload_submission_media(
file: fastapi.UploadFile,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> str:
"""
Upload media (images/videos) for a store listing submission.
Args:
file (UploadFile): The media file to upload
user_id (str): ID of the authenticated user uploading the media
Returns:
str: URL of the uploaded media file
Raises:
HTTPException: If there is an error uploading the media
"""
try:
media_url = await backend.server.v2.store.media.upload_media(
user_id=user_id, file=file
)
return media_url
except Exception:
logger.exception("Exception occurred whilst uploading submission media")
raise

View File

@@ -0,0 +1,544 @@
import datetime
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.testclient
import prisma.enums
import pytest_mock
import backend.server.v2.store.model
import backend.server.v2.store.routes
app = fastapi.FastAPI()
app.include_router(backend.server.v2.store.routes.router)
client = fastapi.testclient.TestClient(app)
def override_auth_middleware():
"""Override auth middleware for testing"""
return {"sub": "test-user-id"}
def override_get_user_id():
"""Override get_user_id for testing"""
return "test-user-id"
app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = (
override_auth_middleware
)
app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = override_get_user_id
def test_get_agents_defaults(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=0,
total_items=0,
total_pages=0,
page_size=10,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert data.pagination.total_pages == 0
assert data.agents == []
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_featured(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="featured-agent",
agent_name="Featured Agent",
agent_image="featured.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Featured agent subheading",
description="Featured agent description",
runs=100,
rating=4.5,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?featured=true")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert data.agents[0].slug == "featured-agent"
mock_db_call.assert_called_once_with(
featured=True,
creator=None,
sorted_by=None,
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_by_creator(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="creator-agent",
agent_name="Creator Agent",
agent_image="agent.jpg",
creator="specific-creator",
creator_avatar="avatar.jpg",
sub_heading="Creator agent subheading",
description="Creator agent description",
runs=50,
rating=4.0,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?creator=specific-creator")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert data.agents[0].creator == "specific-creator"
mock_db_call.assert_called_once_with(
featured=False,
creator="specific-creator",
sorted_by=None,
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_sorted(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="top-agent",
agent_name="Top Agent",
agent_image="top.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Top agent subheading",
description="Top agent description",
runs=1000,
rating=5.0,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?sorted_by=runs")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert data.agents[0].runs == 1000
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by="runs",
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_search(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="search-agent",
agent_name="Search Agent",
agent_image="search.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Search agent subheading",
description="Specific search term description",
runs=75,
rating=4.2,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?search_query=specific")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert "specific" in data.agents[0].description.lower()
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query="specific",
category=None,
page=1,
page_size=20,
)
def test_get_agents_category(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="category-agent",
agent_name="Category Agent",
agent_image="category.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Category agent subheading",
description="Category agent description",
runs=60,
rating=4.1,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?category=test-category")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query=None,
category="test-category",
page=1,
page_size=20,
)
def test_get_agents_pagination(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug=f"agent-{i}",
agent_name=f"Agent {i}",
agent_image=f"agent{i}.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading=f"Agent {i} subheading",
description=f"Agent {i} description",
runs=i * 10,
rating=4.0,
)
for i in range(5)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=2,
total_items=15,
total_pages=3,
page_size=5,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?page=2&page_size=5")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 5
assert data.pagination.current_page == 2
assert data.pagination.page_size == 5
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query=None,
category=None,
page=2,
page_size=5,
)
def test_get_agents_malformed_request(mocker: pytest_mock.MockFixture):
# Test with invalid page number
response = client.get("/agents?page=-1")
assert response.status_code == 422
# Test with invalid page size
response = client.get("/agents?page_size=0")
assert response.status_code == 422
# Test with non-numeric values
response = client.get("/agents?page=abc&page_size=def")
assert response.status_code == 422
# Verify no DB calls were made
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.assert_not_called()
def test_get_agent_details(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentDetails(
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_image=["image1.jpg", "image2.jpg"],
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Test agent subheading",
description="Test agent description",
categories=["category1", "category2"],
runs=100,
rating=4.5,
versions=["1.0.0", "1.1.0"],
last_updated=datetime.datetime.now(),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agent_details")
mock_db_call.return_value = mocked_value
response = client.get("/agents/creator1/test-agent")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentDetails.model_validate(
response.json()
)
assert data.agent_name == "Test Agent"
assert data.creator == "creator1"
mock_db_call.assert_called_once_with(username="creator1", agent_name="test-agent")
def test_get_creators_defaults(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.CreatorsResponse(
creators=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=0,
total_items=0,
total_pages=0,
page_size=10,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creators")
mock_db_call.return_value = mocked_value
response = client.get("/creators")
assert response.status_code == 200
data = backend.server.v2.store.model.CreatorsResponse.model_validate(
response.json()
)
assert data.pagination.total_pages == 0
assert data.creators == []
mock_db_call.assert_called_once_with(
featured=False, search_query=None, sorted_by=None, page=1, page_size=20
)
def test_get_creators_pagination(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.CreatorsResponse(
creators=[
backend.server.v2.store.model.Creator(
name=f"Creator {i}",
username=f"creator{i}",
description=f"Creator {i} description",
avatar_url=f"avatar{i}.jpg",
num_agents=1,
)
for i in range(5)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=2,
total_items=15,
total_pages=3,
page_size=5,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creators")
mock_db_call.return_value = mocked_value
response = client.get("/creators?page=2&page_size=5")
assert response.status_code == 200
data = backend.server.v2.store.model.CreatorsResponse.model_validate(
response.json()
)
assert len(data.creators) == 5
assert data.pagination.current_page == 2
assert data.pagination.page_size == 5
mock_db_call.assert_called_once_with(
featured=False, search_query=None, sorted_by=None, page=2, page_size=5
)
def test_get_creators_malformed_request(mocker: pytest_mock.MockFixture):
# Test with invalid page number
response = client.get("/creators?page=-1")
assert response.status_code == 422
# Test with invalid page size
response = client.get("/creators?page_size=0")
assert response.status_code == 422
# Test with non-numeric values
response = client.get("/creators?page=abc&page_size=def")
assert response.status_code == 422
# Verify no DB calls were made
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creators")
mock_db_call.assert_not_called()
def test_get_creator_details(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.CreatorDetails(
name="Test Creator",
username="creator1",
description="Test creator description",
links=["link1.com", "link2.com"],
avatar_url="avatar.jpg",
agent_rating=4.8,
agent_runs=1000,
top_categories=["category1", "category2"],
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creator_details")
mock_db_call.return_value = mocked_value
response = client.get("/creator/creator1")
assert response.status_code == 200
data = backend.server.v2.store.model.CreatorDetails.model_validate(response.json())
assert data.username == "creator1"
assert data.name == "Test Creator"
mock_db_call.assert_called_once_with(username="creator1")
def test_get_submissions_success(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[
backend.server.v2.store.model.StoreSubmission(
name="Test Agent",
description="Test agent description",
image_urls=["test.jpg"],
date_submitted=datetime.datetime.now(),
status=prisma.enums.SubmissionStatus.APPROVED,
runs=50,
rating=4.2,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_submissions")
mock_db_call.return_value = mocked_value
response = client.get("/submissions")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreSubmissionsResponse.model_validate(
response.json()
)
assert len(data.submissions) == 1
assert data.submissions[0].name == "Test Agent"
assert data.pagination.current_page == 1
mock_db_call.assert_called_once_with(user_id="test-user-id", page=1, page_size=20)
def test_get_submissions_pagination(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=2,
total_items=10,
total_pages=2,
page_size=5,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_submissions")
mock_db_call.return_value = mocked_value
response = client.get("/submissions?page=2&page_size=5")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreSubmissionsResponse.model_validate(
response.json()
)
assert data.pagination.current_page == 2
assert data.pagination.page_size == 5
mock_db_call.assert_called_once_with(user_id="test-user-id", page=2, page_size=5)
def test_get_submissions_malformed_request(mocker: pytest_mock.MockFixture):
# Test with invalid page number
response = client.get("/submissions?page=-1")
assert response.status_code == 422
# Test with invalid page size
response = client.get("/submissions?page_size=0")
assert response.status_code == 422
# Test with non-numeric values
response = client.get("/submissions?page=abc&page_size=def")
assert response.status_code == 422
# Verify no DB calls were made
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_submissions")
mock_db_call.assert_not_called()

View File

@@ -0,0 +1,242 @@
-- CreateEnum
CREATE TYPE "SubmissionStatus" AS ENUM ('DAFT', 'PENDING', 'APPROVED', 'REJECTED');
-- AlterTable
ALTER TABLE "AgentGraphExecution" ADD COLUMN "agentPresetId" TEXT;
-- AlterTable
ALTER TABLE "AgentNodeExecutionInputOutput" ADD COLUMN "agentPresetId" TEXT;
-- AlterTable
ALTER TABLE "AnalyticsDetails" ALTER COLUMN "id" DROP DEFAULT;
-- AlterTable
ALTER TABLE "AnalyticsMetrics" ALTER COLUMN "id" DROP DEFAULT;
-- CreateTable
CREATE TABLE "AgentPreset" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"userId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
CONSTRAINT "AgentPreset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserAgent" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
"agentPresetId" TEXT,
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
"isCreatedByUser" BOOLEAN NOT NULL DEFAULT false,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "UserAgent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentGraphExecutionSchedule" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3),
"agentGraphId" TEXT NOT NULL,
"agentGraphVersion" INTEGER NOT NULL DEFAULT 1,
"schedule" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"inputData" TEXT NOT NULL,
"lastUpdated" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"agentPresetId" TEXT,
CONSTRAINT "AgentGraphExecutionSchedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Profile" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
"name" TEXT NOT NULL,
"username" TEXT NOT NULL,
"description" TEXT NOT NULL,
"links" TEXT[],
"avatarUrl" TEXT,
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListing" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
"owningUserId" TEXT NOT NULL,
CONSTRAINT "StoreListing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListingVersion" (
"id" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"subHeading" TEXT NOT NULL,
"videoUrl" TEXT,
"imageUrls" TEXT[],
"description" TEXT NOT NULL,
"categories" TEXT[],
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"storeListingId" TEXT,
CONSTRAINT "StoreListingVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListingReview" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"storeListingVersionId" TEXT NOT NULL,
"reviewByUserId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"comments" TEXT,
CONSTRAINT "StoreListingReview_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListingSubmission" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"storeListingId" TEXT NOT NULL,
"storeListingVersionId" TEXT NOT NULL,
"reviewerId" TEXT NOT NULL,
"Status" "SubmissionStatus" NOT NULL DEFAULT 'PENDING',
"reviewComments" TEXT,
CONSTRAINT "StoreListingSubmission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AgentPreset_userId_idx" ON "AgentPreset"("userId");
-- CreateIndex
CREATE INDEX "UserAgent_userId_idx" ON "UserAgent"("userId");
-- CreateIndex
CREATE INDEX "AgentGraphExecutionSchedule_isEnabled_idx" ON "AgentGraphExecutionSchedule"("isEnabled");
-- CreateIndex
CREATE UNIQUE INDEX "Profile_username_key" ON "Profile"("username");
-- CreateIndex
CREATE INDEX "Profile_username_idx" ON "Profile"("username");
-- CreateIndex
CREATE INDEX "Profile_userId_idx" ON "Profile"("userId");
-- CreateIndex
CREATE INDEX "StoreListing_isApproved_idx" ON "StoreListing"("isApproved");
-- CreateIndex
CREATE INDEX "StoreListing_agentId_idx" ON "StoreListing"("agentId");
-- CreateIndex
CREATE INDEX "StoreListing_owningUserId_idx" ON "StoreListing"("owningUserId");
-- CreateIndex
CREATE INDEX "StoreListingVersion_agentId_agentVersion_isApproved_idx" ON "StoreListingVersion"("agentId", "agentVersion", "isApproved");
-- CreateIndex
CREATE UNIQUE INDEX "StoreListingVersion_agentId_agentVersion_key" ON "StoreListingVersion"("agentId", "agentVersion");
-- CreateIndex
CREATE INDEX "StoreListingSubmission_storeListingId_idx" ON "StoreListingSubmission"("storeListingId");
-- CreateIndex
CREATE INDEX "StoreListingSubmission_Status_idx" ON "StoreListingSubmission"("Status");
-- AddForeignKey
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAgent" ADD CONSTRAINT "UserAgent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAgent" ADD CONSTRAINT "UserAgent_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAgent" ADD CONSTRAINT "UserAgent_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraphExecution" ADD CONSTRAINT "AgentGraphExecution_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentNodeExecutionInputOutput" ADD CONSTRAINT "AgentNodeExecutionInputOutput_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraphExecutionSchedule" ADD CONSTRAINT "AgentGraphExecutionSchedule_agentGraphId_agentGraphVersion_fkey" FOREIGN KEY ("agentGraphId", "agentGraphVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraphExecutionSchedule" ADD CONSTRAINT "AgentGraphExecutionSchedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraphExecutionSchedule" ADD CONSTRAINT "AgentGraphExecutionSchedule_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingVersion" ADD CONSTRAINT "StoreListingVersion_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingVersion" ADD CONSTRAINT "StoreListingVersion_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingReview" ADD CONSTRAINT "StoreListingReview_storeListingVersionId_fkey" FOREIGN KEY ("storeListingVersionId") REFERENCES "StoreListingVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingReview" ADD CONSTRAINT "StoreListingReview_reviewByUserId_fkey" FOREIGN KEY ("reviewByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingSubmission" ADD CONSTRAINT "StoreListingSubmission_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingSubmission" ADD CONSTRAINT "StoreListingSubmission_storeListingVersionId_fkey" FOREIGN KEY ("storeListingVersionId") REFERENCES "StoreListingVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingSubmission" ADD CONSTRAINT "StoreListingSubmission_reviewerId_fkey" FOREIGN KEY ("reviewerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,118 @@
BEGIN;
CREATE VIEW "StoreAgent" AS
WITH ReviewStats AS (
SELECT
sl."id" AS "storeListingId",
COUNT(sr.id) AS review_count,
AVG(CAST(sr.score AS DECIMAL)) AS avg_rating
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl."id"
JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
WHERE sl."isDeleted" = FALSE
GROUP BY sl."id"
),
AgentRuns AS (
SELECT "agentGraphId", COUNT(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
)
SELECT
sl.id AS listing_id,
slv."createdAt" AS updated_at,
slv.slug,
a.name AS agent_name,
slv."videoUrl" AS agent_video,
COALESCE(slv."imageUrls", ARRAY[]::TEXT[]) AS agent_image,
slv."isFeatured" AS featured,
p.username AS creator_username,
p."avatarUrl" AS creator_avatar,
slv."subHeading" AS sub_heading,
slv.description,
slv.categories,
COALESCE(ar.run_count, 0) AS runs,
CAST(COALESCE(rs.avg_rating, 0.0) AS DOUBLE PRECISION) AS rating,
ARRAY_AGG(DISTINCT CAST(slv.version AS TEXT)) AS versions
FROM "StoreListing" sl
JOIN "AgentGraph" a ON sl."agentId" = a.id AND sl."agentVersion" = a."version"
LEFT JOIN "Profile" p ON sl."owningUserId" = p."userId"
LEFT JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN ReviewStats rs ON sl.id = rs."storeListingId"
LEFT JOIN AgentRuns ar ON a.id = ar."agentGraphId"
WHERE sl."isDeleted" = FALSE
AND sl."isApproved" = TRUE
GROUP BY sl.id, slv.slug, slv."createdAt", a.name, slv."videoUrl", slv."imageUrls", slv."isFeatured",
p.username, p."avatarUrl", slv."subHeading", slv.description, slv.categories,
ar.run_count, rs.avg_rating;
CREATE VIEW "Creator" AS
WITH AgentStats AS (
SELECT
p.username,
COUNT(DISTINCT sl.id) as num_agents,
AVG(CAST(COALESCE(sr.score, 0) AS DECIMAL)) as agent_rating,
SUM(COALESCE(age.run_count, 0)) as agent_runs
FROM "Profile" p
LEFT JOIN "StoreListing" sl ON sl."owningUserId" = p."userId"
LEFT JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "agentGraphId", COUNT(*) as run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
) age ON age."agentGraphId" = sl."agentId"
WHERE sl."isDeleted" = FALSE AND sl."isApproved" = TRUE
GROUP BY p.username
)
SELECT
p.username,
p.name,
p."avatarUrl" as avatar_url,
p.description,
ARRAY_AGG(DISTINCT c) FILTER (WHERE c IS NOT NULL) as top_categories,
p.links,
COALESCE(ast.num_agents, 0) as num_agents,
COALESCE(ast.agent_rating, 0.0) as agent_rating,
COALESCE(ast.agent_runs, 0) as agent_runs
FROM "Profile" p
LEFT JOIN AgentStats ast ON ast.username = p.username
LEFT JOIN LATERAL (
SELECT UNNEST(slv.categories) as c
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
WHERE sl."owningUserId" = p."userId"
AND sl."isDeleted" = FALSE
AND sl."isApproved" = TRUE
) cats ON true
GROUP BY p.username, p.name, p."avatarUrl", p.description, p.links,
ast.num_agents, ast.agent_rating, ast.agent_runs;
CREATE VIEW "StoreSubmission" AS
SELECT
sl.id as listing_id,
sl."owningUserId" as user_id,
slv."agentId" as agent_id,
slv."version" as agent_version,
slv.slug,
slv.name,
slv."subHeading" as sub_heading,
slv.description,
slv."imageUrls" as image_urls,
slv."createdAt" as date_submitted,
COALESCE(sls."Status", 'PENDING') as status,
COALESCE(ar.run_count, 0) as runs,
CAST(COALESCE(AVG(CAST(sr.score AS DECIMAL)), 0.0) AS DOUBLE PRECISION) as rating
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingSubmission" sls ON sls."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "agentGraphId", COUNT(*) as run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
) ar ON ar."agentGraphId" = slv."agentId"
WHERE sl."isDeleted" = FALSE
GROUP BY sl.id, sl."owningUserId", slv."agentId", slv."version", slv.slug, slv.name, slv."subHeading",
slv.description, slv."imageUrls", slv."createdAt", sls."Status", ar.run_count;
COMMIT;

File diff suppressed because it is too large Load Diff

View File

@@ -48,8 +48,10 @@ googlemaps = "^4.10.0"
replicate = "^1.0.4"
pinecone = "^5.3.1"
cryptography = "^43.0.3"
python-multipart = "^0.0.17"
sqlalchemy = "^2.0.36"
psycopg2-binary = "^2.9.10"
google-cloud-storage = "^2.18.2"
launchdarkly-server-sdk = "^9.8.0"
[tool.poetry.group.dev.dependencies]
poethepoet = "^0.31.0"
@@ -61,6 +63,8 @@ pyright = "^1.1.389"
isort = "^5.13.2"
black = "^24.10.0"
aiohappyeyeballs = "^2.4.3"
pytest-mock = "^3.14.0"
faker = "^30.8.2"
[build-system]
requires = ["poetry-core"]

View File

@@ -8,6 +8,7 @@ generator client {
provider = "prisma-client-py"
recursive_type_depth = 5
interface = "asyncio"
previewFeatures = ["views"]
}
// User model to mirror Auth provider users
@@ -21,13 +22,22 @@ model User {
integrations String @default("")
// Relations
AgentGraphs AgentGraph[]
AgentGraphExecutions AgentGraphExecution[]
AgentGraphs AgentGraph[]
AgentGraphExecutions AgentGraphExecution[]
AnalyticsDetails AnalyticsDetails[]
AnalyticsMetrics AnalyticsMetrics[]
UserBlockCredit UserBlockCredit[]
AgentPreset AgentPreset[]
UserAgent UserAgent[]
Profile Profile[]
StoreListing StoreListing[]
StoreListingReview StoreListingReview[]
StoreListingSubmission StoreListingSubmission[]
APIKeys APIKey[]
AgentGraphExecutionSchedule AgentGraphExecutionSchedule[]
IntegrationWebhooks IntegrationWebhook[]
AnalyticsDetails AnalyticsDetails[]
AnalyticsMetrics AnalyticsMetrics[]
UserBlockCredit UserBlockCredit[]
APIKeys APIKey[]
@@index([id])
@@index([email])
@@ -47,14 +57,90 @@ model AgentGraph {
// Link to User model
userId String
// FIX: Do not cascade delete the agent when the user is deleted
// This allows us to delete user data with deleting the agent which maybe in use by other users
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
AgentNodes AgentNode[]
AgentGraphExecution AgentGraphExecution[]
AgentNodes AgentNode[]
AgentGraphExecution AgentGraphExecution[]
AgentPreset AgentPreset[]
UserAgent UserAgent[]
StoreListing StoreListing[]
StoreListingVersion StoreListingVersion?
AgentGraphExecutionSchedule AgentGraphExecutionSchedule[]
@@id(name: "graphVersionId", [id, version])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////////////// USER SPECIFIC DATA ////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// An AgentPrest is an Agent + User Configuration of that agent.
// For example, if someone has created a weather agent and they want to set it up to
// Inform them of extreme weather warnings in Texas, the agent with the configuration to set it to
// monitor texas, along with the cron setup or webhook tiggers, is an AgentPreset
model AgentPreset {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
name String
description String
// For agents that can be triggered by webhooks or cronjob
// This bool allows us to disable a configured agent without deleting it
isActive Boolean @default(true)
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade)
InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData")
UserAgents UserAgent[]
AgentExecutionSchedule AgentGraphExecutionSchedule[]
AgentExecution AgentGraphExecution[]
@@index([userId])
}
// For the library page
// It is a user controlled list of agents, that they will see in there library
model UserAgent {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version])
agentPresetId String?
AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id])
isFavorite Boolean @default(false)
isCreatedByUser Boolean @default(false)
isArchived Boolean @default(false)
isDeleted Boolean @default(false)
@@index([userId])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////// AGENT DEFINITION AND EXECUTION TABLES ////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// This model describes a single node in the Agent Graph/Flow (Multi Agent System).
model AgentNode {
id String @id @default(uuid())
@@ -146,7 +232,9 @@ model AgentGraphExecution {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stats String? // JSON serialized object
stats String? // JSON serialized object
AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id])
agentPresetId String?
}
// This model describes the execution of an AgentNode.
@@ -187,10 +275,39 @@ model AgentNodeExecutionInputOutput {
referencedByOutputExecId String?
ReferencedByOutputExec AgentNodeExecution? @relation("AgentNodeExecutionOutput", fields: [referencedByOutputExecId], references: [id], onDelete: Cascade)
agentPresetId String?
AgentPreset AgentPreset? @relation("AgentPresetsInputData", fields: [agentPresetId], references: [id])
// Input and Output pin names are unique for each AgentNodeExecution.
@@unique([referencedByInputExecId, referencedByOutputExecId, name])
}
// This model describes the recurring execution schedule of an Agent.
model AgentGraphExecutionSchedule {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
agentGraphId String
agentGraphVersion Int @default(1)
AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version], onDelete: Cascade)
schedule String // cron expression
isEnabled Boolean @default(true)
inputData String // JSON serialized object
// default and set the value on each update, lastUpdated field has no time zone.
lastUpdated DateTime @updatedAt
// Link to User model
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id])
agentPresetId String?
@@index([isEnabled])
}
// Webhook that is registered with a provider and propagates to one or more nodes
model IntegrationWebhook {
id String @id @default(uuid())
@@ -216,7 +333,7 @@ model IntegrationWebhook {
model AnalyticsDetails {
// PK uses gen_random_uuid() to allow the db inserts to happen outside of prisma
// typical uuid() inserts are handled by prisma
id String @id @default(dbgenerated("gen_random_uuid()"))
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -238,8 +355,13 @@ model AnalyticsDetails {
@@index([type])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// METRICS TRACKING TABLES ////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model AnalyticsMetrics {
id String @id @default(dbgenerated("gen_random_uuid()"))
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -261,6 +383,11 @@ enum UserBlockCreditType {
USAGE
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////// ACCOUNTING AND CREDIT SYSTEM TABLES //////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model UserBlockCredit {
transactionKey String @default(uuid())
createdAt DateTime @default(now())
@@ -280,6 +407,200 @@ model UserBlockCredit {
@@id(name: "creditTransactionIdentifier", [transactionKey, userId])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// Store TABLES ///////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model Profile {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
// Only 1 of user or group can be set.
// The user this profile belongs to, if any.
userId String?
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
username String @unique
description String
links String[]
avatarUrl String?
@@index([username])
@@index([userId])
}
view Creator {
username String @unique
name String
avatar_url String
description String
top_categories String[]
links String[]
num_agents Int
agent_rating Float
agent_runs Int
}
view StoreAgent {
listing_id String @id
updated_at DateTime
slug String
agent_name String
agent_video String?
agent_image String[]
featured Boolean @default(false)
creator_username String
creator_avatar String
sub_heading String
description String
categories String[]
runs Int
rating Float
versions String[]
@@unique([creator_username, slug])
@@index([creator_username])
@@index([featured])
@@index([categories])
}
view StoreSubmission {
listing_id String @id
user_id String
slug String
name String
sub_heading String
description String
image_urls String[]
date_submitted DateTime
status SubmissionStatus
runs Int
rating Float
agent_id String
agent_version Int
@@index([user_id])
}
model StoreListing {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
isDeleted Boolean @default(false)
// Not needed but makes lookups faster
isApproved Boolean @default(false)
// The agent link here is only so we can do lookup on agentId, for the listing the StoreListingVersion is used.
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade)
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
StoreListingVersions StoreListingVersion[]
StoreListingSubmission StoreListingSubmission[]
@@index([isApproved])
@@index([agentId])
@@index([owningUserId])
}
model StoreListingVersion {
id String @id @default(uuid())
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
// The agent and version to be listed on the store
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version])
// The detials for this version of the agent, this allows the author to update the details of the agent,
// But still allow using old versions of the agent with there original details.
// TODO: Create a database view that shows only the latest version of each store listing.
slug String
name String
subHeading String
videoUrl String?
imageUrls String[]
description String
categories String[]
isFeatured Boolean @default(false)
isDeleted Boolean @default(false)
// Old versions can be made unavailable by the author if desired
isAvailable Boolean @default(true)
// Not needed but makes lookups faster
isApproved Boolean @default(false)
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: Cascade)
storeListingId String?
StoreListingSubmission StoreListingSubmission[]
// Reviews are on a specific version, but then aggregated up to the listing.
// This allows us to provide a review filter to current version of the agent.
StoreListingReview StoreListingReview[]
@@unique([agentId, agentVersion])
@@index([agentId, agentVersion, isApproved])
}
model StoreListingReview {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
storeListingVersionId String
StoreListingVersion StoreListingVersion @relation(fields: [storeListingVersionId], references: [id], onDelete: Cascade)
reviewByUserId String
ReviewByUser User @relation(fields: [reviewByUserId], references: [id])
score Int
comments String?
}
enum SubmissionStatus {
DAFT
PENDING
APPROVED
REJECTED
}
model StoreListingSubmission {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
storeListingId String
StoreListing StoreListing @relation(fields: [storeListingId], references: [id], onDelete: Cascade)
storeListingVersionId String
StoreListingVersion StoreListingVersion @relation(fields: [storeListingVersionId], references: [id], onDelete: Cascade)
reviewerId String
Reviewer User @relation(fields: [reviewerId], references: [id])
Status SubmissionStatus @default(PENDING)
reviewComments String?
@@index([storeListingId])
@@index([Status])
}
enum APIKeyPermission {
EXECUTE_GRAPH // Can execute agent graphs
READ_GRAPH // Can get graph versions and details
@@ -290,7 +611,7 @@ enum APIKeyPermission {
model APIKey {
id String @id @default(uuid())
name String
prefix String // First 8 chars for identification
prefix String // First 8 chars for identification
postfix String
key String @unique // Hashed key
status APIKeyStatus @default(ACTIVE)

View File

@@ -0,0 +1,448 @@
import asyncio
import random
from datetime import datetime
import prisma.enums
from faker import Faker
from prisma import Prisma
faker = Faker()
# Constants for data generation limits
# Base entities
NUM_USERS = 100 # Creates 100 user records
NUM_AGENT_BLOCKS = 100 # Creates 100 agent block templates
# Per-user entities
MIN_GRAPHS_PER_USER = 1 # Each user will have between 1-5 graphs
MAX_GRAPHS_PER_USER = 5 # Total graphs: 500-2500 (NUM_USERS * MIN/MAX_GRAPHS)
# Per-graph entities
MIN_NODES_PER_GRAPH = 2 # Each graph will have between 2-5 nodes
MAX_NODES_PER_GRAPH = (
5 # Total nodes: 1000-2500 (GRAPHS_PER_USER * NUM_USERS * MIN/MAX_NODES)
)
# Additional per-user entities
MIN_PRESETS_PER_USER = 1 # Each user will have between 1-2 presets
MAX_PRESETS_PER_USER = 5 # Total presets: 500-2500 (NUM_USERS * MIN/MAX_PRESETS)
MIN_AGENTS_PER_USER = 1 # Each user will have between 1-2 agents
MAX_AGENTS_PER_USER = 10 # Total agents: 500-5000 (NUM_USERS * MIN/MAX_AGENTS)
# Execution and review records
MIN_EXECUTIONS_PER_GRAPH = 1 # Each graph will have between 1-5 execution records
MAX_EXECUTIONS_PER_GRAPH = (
20 # Total executions: 1000-5000 (TOTAL_GRAPHS * MIN/MAX_EXECUTIONS)
)
MIN_REVIEWS_PER_VERSION = 1 # Each version will have between 1-3 reviews
MAX_REVIEWS_PER_VERSION = 5 # Total reviews depends on number of versions created
def get_image():
url = faker.image_url()
while "placekitten.com" in url:
url = faker.image_url()
return url
async def main():
db = Prisma()
await db.connect()
# Insert Users
print(f"Inserting {NUM_USERS} users")
users = []
for _ in range(NUM_USERS):
user = await db.user.create(
data={
"id": str(faker.uuid4()),
"email": faker.unique.email(),
"name": faker.name(),
"metadata": prisma.Json({}),
"integrations": "",
}
)
users.append(user)
# Insert AgentBlocks
agent_blocks = []
print(f"Inserting {NUM_AGENT_BLOCKS} agent blocks")
for _ in range(NUM_AGENT_BLOCKS):
block = await db.agentblock.create(
data={
"name": f"{faker.word()}_{str(faker.uuid4())[:8]}",
"inputSchema": "{}",
"outputSchema": "{}",
}
)
agent_blocks.append(block)
# Insert AgentGraphs
agent_graphs = []
print(f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER} agent graphs")
for user in users:
for _ in range(
random.randint(MIN_GRAPHS_PER_USER, MAX_GRAPHS_PER_USER)
): # Adjust the range to create more graphs per user if desired
graph = await db.agentgraph.create(
data={
"name": faker.sentence(nb_words=3),
"description": faker.text(max_nb_chars=200),
"userId": user.id,
"isActive": True,
"isTemplate": False,
}
)
agent_graphs.append(graph)
# Insert AgentNodes
agent_nodes = []
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_NODES_PER_GRAPH} agent nodes"
)
for graph in agent_graphs:
num_nodes = random.randint(MIN_NODES_PER_GRAPH, MAX_NODES_PER_GRAPH)
for _ in range(num_nodes): # Create 5 AgentNodes per graph
block = random.choice(agent_blocks)
node = await db.agentnode.create(
data={
"agentBlockId": block.id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"constantInput": "{}",
"metadata": "{}",
}
)
agent_nodes.append(node)
# Insert AgentPresets
agent_presets = []
print(f"Inserting {NUM_USERS * MAX_PRESETS_PER_USER} agent presets")
for user in users:
num_presets = random.randint(MIN_PRESETS_PER_USER, MAX_PRESETS_PER_USER)
for _ in range(num_presets): # Create 1 AgentPreset per user
graph = random.choice(agent_graphs)
preset = await db.agentpreset.create(
data={
"name": faker.sentence(nb_words=3),
"description": faker.text(max_nb_chars=200),
"userId": user.id,
"agentId": graph.id,
"agentVersion": graph.version,
"isActive": True,
}
)
agent_presets.append(preset)
# Insert UserAgents
user_agents = []
print(f"Inserting {NUM_USERS * MAX_AGENTS_PER_USER} user agents")
for user in users:
num_agents = random.randint(MIN_AGENTS_PER_USER, MAX_AGENTS_PER_USER)
for _ in range(num_agents): # Create 1 UserAgent per user
graph = random.choice(agent_graphs)
preset = random.choice(agent_presets)
user_agent = await db.useragent.create(
data={
"userId": user.id,
"agentId": graph.id,
"agentVersion": graph.version,
"agentPresetId": preset.id,
"isFavorite": random.choice([True, False]),
"isCreatedByUser": random.choice([True, False]),
"isArchived": random.choice([True, False]),
"isDeleted": random.choice([True, False]),
}
)
user_agents.append(user_agent)
# Insert AgentGraphExecutions
# Insert AgentGraphExecutions
agent_graph_executions = []
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_EXECUTIONS_PER_GRAPH} agent graph executions"
)
graph_execution_data = []
for graph in agent_graphs:
user = random.choice(users)
num_executions = random.randint(
MIN_EXECUTIONS_PER_GRAPH, MAX_EXECUTIONS_PER_GRAPH
)
for _ in range(num_executions):
matching_presets = [p for p in agent_presets if p.agentId == graph.id]
preset = (
random.choice(matching_presets)
if matching_presets and random.random() < 0.5
else None
)
graph_execution_data.append(
{
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"userId": user.id,
"executionStatus": prisma.enums.AgentExecutionStatus.COMPLETED,
"startedAt": faker.date_time_this_year(),
"agentPresetId": preset.id if preset else None,
}
)
agent_graph_executions = await db.agentgraphexecution.create_many(
data=graph_execution_data
)
# Need to fetch the created records since create_many doesn't return them
agent_graph_executions = await db.agentgraphexecution.find_many()
# Insert AgentNodeExecutions
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_EXECUTIONS_PER_GRAPH} agent node executions"
)
node_execution_data = []
for execution in agent_graph_executions:
nodes = [
node for node in agent_nodes if node.agentGraphId == execution.agentGraphId
]
for node in nodes:
node_execution_data.append(
{
"agentGraphExecutionId": execution.id,
"agentNodeId": node.id,
"executionStatus": prisma.enums.AgentExecutionStatus.COMPLETED,
"addedTime": datetime.now(),
}
)
agent_node_executions = await db.agentnodeexecution.create_many(
data=node_execution_data
)
# Need to fetch the created records since create_many doesn't return them
agent_node_executions = await db.agentnodeexecution.find_many()
# Insert AgentNodeExecutionInputOutput
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_EXECUTIONS_PER_GRAPH} agent node execution input/outputs"
)
input_output_data = []
for node_execution in agent_node_executions:
# Input data
input_output_data.append(
{
"name": "input1",
"data": "{}",
"time": datetime.now(),
"referencedByInputExecId": node_execution.id,
}
)
# Output data
input_output_data.append(
{
"name": "output1",
"data": "{}",
"time": datetime.now(),
"referencedByOutputExecId": node_execution.id,
}
)
await db.agentnodeexecutioninputoutput.create_many(data=input_output_data)
# Insert AgentGraphExecutionSchedules
agent_graph_execution_schedules = []
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER} agent graph execution schedules"
)
for graph in agent_graphs:
user = random.choice(users)
schedule = await db.agentgraphexecutionschedule.create(
data={
"id": str(faker.uuid4()),
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"schedule": "* * * * *",
"isEnabled": True,
"inputData": "{}",
"userId": user.id,
"lastUpdated": datetime.now(),
}
)
agent_graph_execution_schedules.append(schedule)
# Insert AgentNodeLinks
print(f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER} agent node links")
for graph in agent_graphs:
nodes = [node for node in agent_nodes if node.agentGraphId == graph.id]
if len(nodes) >= 2:
source_node = nodes[0]
sink_node = nodes[1]
await db.agentnodelink.create(
data={
"agentNodeSourceId": source_node.id,
"sourceName": "output1",
"agentNodeSinkId": sink_node.id,
"sinkName": "input1",
"isStatic": False,
}
)
# Insert AnalyticsDetails
print(f"Inserting {NUM_USERS} analytics details")
for user in users:
for _ in range(1):
await db.analyticsdetails.create(
data={
"userId": user.id,
"type": faker.word(),
"data": prisma.Json({}),
"dataIndex": faker.word(),
}
)
# Insert AnalyticsMetrics
print(f"Inserting {NUM_USERS} analytics metrics")
for user in users:
for _ in range(1):
await db.analyticsmetrics.create(
data={
"userId": user.id,
"analyticMetric": faker.word(),
"value": random.uniform(0, 100),
"dataString": faker.word(),
}
)
# Insert UserBlockCredit
print(f"Inserting {NUM_USERS} user block credits")
for user in users:
for _ in range(1):
block = random.choice(agent_blocks)
await db.userblockcredit.create(
data={
"transactionKey": str(faker.uuid4()),
"userId": user.id,
"blockId": block.id,
"amount": random.randint(1, 100),
"type": (
prisma.enums.UserBlockCreditType.TOP_UP
if random.random() < 0.5
else prisma.enums.UserBlockCreditType.USAGE
),
"metadata": prisma.Json({}),
}
)
# Insert Profiles
profiles = []
print(f"Inserting {NUM_USERS} profiles")
for user in users:
profile = await db.profile.create(
data={
"userId": user.id,
"name": user.name or faker.name(),
"username": faker.unique.user_name(),
"description": faker.text(),
"links": [faker.url() for _ in range(3)],
"avatarUrl": get_image(),
}
)
profiles.append(profile)
# Insert StoreListings
store_listings = []
print(f"Inserting {NUM_USERS} store listings")
for graph in agent_graphs:
user = random.choice(users)
listing = await db.storelisting.create(
data={
"agentId": graph.id,
"agentVersion": graph.version,
"owningUserId": user.id,
"isApproved": random.choice([True, False]),
}
)
store_listings.append(listing)
# Insert StoreListingVersions
store_listing_versions = []
print(f"Inserting {NUM_USERS} store listing versions")
for listing in store_listings:
graph = [g for g in agent_graphs if g.id == listing.agentId][0]
version = await db.storelistingversion.create(
data={
"agentId": graph.id,
"agentVersion": graph.version,
"slug": faker.slug(),
"name": graph.name or faker.sentence(nb_words=3),
"subHeading": faker.sentence(),
"videoUrl": faker.url(),
"imageUrls": [get_image() for _ in range(3)],
"description": faker.text(),
"categories": [faker.word() for _ in range(3)],
"isFeatured": random.choice([True, False]),
"isAvailable": True,
"isApproved": random.choice([True, False]),
"storeListingId": listing.id,
}
)
store_listing_versions.append(version)
# Insert StoreListingReviews
print(f"Inserting {NUM_USERS * MAX_REVIEWS_PER_VERSION} store listing reviews")
for version in store_listing_versions:
num_reviews = random.randint(MIN_REVIEWS_PER_VERSION, MAX_REVIEWS_PER_VERSION)
for _ in range(num_reviews):
user = random.choice(users)
await db.storelistingreview.create(
data={
"storeListingVersionId": version.id,
"reviewByUserId": user.id,
"score": random.randint(1, 5),
"comments": faker.text(),
}
)
# Insert StoreListingSubmissions
print(f"Inserting {NUM_USERS} store listing submissions")
for listing in store_listings:
version = random.choice(store_listing_versions)
reviewer = random.choice(users)
status: prisma.enums.SubmissionStatus = random.choice(
[
prisma.enums.SubmissionStatus.PENDING,
prisma.enums.SubmissionStatus.APPROVED,
prisma.enums.SubmissionStatus.REJECTED,
]
)
await db.storelistingsubmission.create(
data={
"storeListingId": listing.id,
"storeListingVersionId": version.id,
"reviewerId": reviewer.id,
"Status": status,
"reviewComments": faker.text(),
}
)
# Insert APIKeys
print(f"Inserting {NUM_USERS} api keys")
for user in users:
await db.apikey.create(
data={
"name": faker.word(),
"prefix": str(faker.uuid4())[:8],
"postfix": str(faker.uuid4())[-8:],
"key": str(faker.sha256()),
"status": prisma.enums.APIKeyStatus.ACTIVE,
"permissions": [
prisma.enums.APIKeyPermission.EXECUTE_GRAPH,
prisma.enums.APIKeyPermission.READ_GRAPH,
],
"description": faker.text(),
"userId": user.id,
}
)
await db.disconnect()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -3,6 +3,11 @@ NEXT_PUBLIC_AGPT_SERVER_URL=http://localhost:8006/api
NEXT_PUBLIC_AGPT_WS_SERVER_URL=ws://localhost:8001/ws
NEXT_PUBLIC_AGPT_MARKETPLACE_URL=http://localhost:8015/api/v1/market
## Locale settings
NEXT_PUBLIC_DEFAULT_LOCALE=en
NEXT_PUBLIC_LOCALES=en,es
## Supabase credentials
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000

View File

@@ -45,3 +45,6 @@ node_modules/
*storybook.log
storybook-static
*.ignore.*
*.ign.*
.cursorrules

View File

@@ -3,12 +3,15 @@ import type { StorybookConfig } from "@storybook/nextjs";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-a11y",
"@storybook/addon-onboarding",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
features: {
experimentalRSC: true,
},
framework: {
name: "@storybook/nextjs",
options: {},

View File

@@ -1,8 +1,15 @@
import type { Preview } from "@storybook/react";
import { initialize, mswLoader } from "msw-storybook-addon";
import "../src/app/globals.css";
// Initialize MSW
initialize();
const preview: Preview = {
parameters: {
nextjs: {
appDirectory: true,
},
controls: {
matchers: {
color: /(background|color)$/i,
@@ -10,6 +17,7 @@ const preview: Preview = {
},
},
},
loaders: [mswLoader],
};
export default preview;

View File

@@ -0,0 +1,22 @@
import type { TestRunnerConfig } from "@storybook/test-runner";
import { injectAxe, checkA11y } from "axe-playwright";
/*
* See https://storybook.js.org/docs/writing-tests/test-runner#test-hook-api
* to learn more about the test-runner hooks API.
*/
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page) {
await checkA11y(page, "#storybook-root", {
detailedReport: true,
detailedReportOptions: {
html: true,
},
});
},
};
export default config;

View File

@@ -3,16 +3,16 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["images.unsplash.com"],
},
async redirects() {
return [
{
source: "/monitor", // FIXME: Remove after 2024-09-01
destination: "/",
permanent: false,
},
];
domains: [
"images.unsplash.com",
"ddz4ak4pa3d19.cloudfront.net",
"upload.wikimedia.org",
"storage.googleapis.com",
"picsum.photos", // for placeholder images
"dummyimage.com", // for placeholder images
"placekitten.com", // for placeholder images
],
},
output: "standalone",
// TODO: Re-enable TypeScript checks once current issues are resolved
@@ -46,7 +46,7 @@ export default withSentryConfig(nextConfig, {
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
tunnelRoute: "/store",
// Hides source maps from generated client bundles
hideSourceMaps: true,

View File

@@ -23,6 +23,7 @@
"defaults"
],
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.5",
"@faker-js/faker": "^9.2.0",
"@hookform/resolvers": "^3.9.1",
"@next/third-parties": "^15.0.3",
@@ -50,15 +51,20 @@
"@tanstack/react-table": "^8.20.5",
"@xyflow/react": "^12.3.5",
"ajv": "^8.17.1",
"boring-avatars": "^1.11.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"cookie": "1.0.2",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"elliptic": "6.6.1",
"embla-carousel-react": "^8.3.0",
"framer-motion": "^11.11.9",
"geist": "^1.3.1",
"elliptic": "6.6.0",
"lucide-react": "^0.460.0",
"moment": "^2.30.1",
"negotiator": "^1.0.0",
"next": "^14.2.13",
"next-themes": "^0.4.3",
"react": "^18",
@@ -77,24 +83,30 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@playwright/test": "^1.49.0",
"@storybook/addon-essentials": "^8.4.5",
"@storybook/addon-interactions": "^8.4.5",
"@storybook/addon-links": "^8.4.5",
"@storybook/addon-onboarding": "^8.4.5",
"@storybook/blocks": "^8.4.5",
"@storybook/nextjs": "^8.4.5",
"@playwright/test": "^1.48.2",
"@storybook/addon-a11y": "^8.3.5",
"@storybook/addon-essentials": "^8.4.2",
"@storybook/addon-interactions": "^8.4.2",
"@storybook/addon-links": "^8.4.2",
"@storybook/addon-onboarding": "^8.4.2",
"@storybook/blocks": "^8.4.2",
"@storybook/nextjs": "^8.4.2",
"@storybook/react": "^8.3.5",
"@storybook/test": "^8.3.5",
"@storybook/test-runner": "^0.19.1",
"@types/node": "^22.9.3",
"@types/negotiator": "^0.6.3",
"@types/node": "^22.9.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-modal": "^3.16.3",
"concurrently": "^9.1.0",
"axe-playwright": "^2.0.3",
"chromatic": "^11.12.5",
"concurrently": "^9.0.1",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"eslint-plugin-storybook": "^0.11.1",
"eslint-plugin-storybook": "^0.11.0",
"msw": "^2.5.2",
"msw-storybook-addon": "^2.0.3",
"postcss": "^8",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.9",
@@ -102,5 +114,10 @@
"tailwindcss": "^3.4.15",
"typescript": "^5"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"msw": {
"workerDirectory": [
"public"
]
}
}

View File

@@ -0,0 +1,295 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.6'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[responseClone.body],
)
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
})
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}

View File

@@ -0,0 +1,14 @@
import "server-only";
const dictionaries: Record<string, () => Promise<any>> = {
en: () => import("./dictionaries/en.json").then((module) => module.default),
es: () => import("./dictionaries/es.json").then((module) => module.default),
};
export const getDictionary = async (locale: string): Promise<any> => {
const localeKey = locale || "en";
if (!dictionaries[localeKey]) {
return dictionaries["en"]();
}
return dictionaries[localeKey]();
};

View File

@@ -0,0 +1,22 @@
{
"auth": {
"signIn": "Sign In",
"email": "Email",
"password": "Password",
"submit": "Submit",
"error": "Invalid login credentials"
},
"dashboard": {
"welcome": "Welcome to your dashboard",
"stats": "Your Stats",
"recentActivity": "Recent Activity"
},
"admin": {
"title": "Admin Dashboard",
"users": "Users Management",
"settings": "System Settings"
},
"home": {
"welcome": "Welcome to the Home Page"
}
}

View File

@@ -0,0 +1,22 @@
{
"auth": {
"signIn": "Iniciar Sesión",
"email": "Correo electrónico",
"password": "Contraseña",
"submit": "Enviar",
"error": "Credenciales inválidas"
},
"dashboard": {
"welcome": "Bienvenido a tu panel",
"stats": "Tus Estadísticas",
"recentActivity": "Actividad Reciente"
},
"admin": {
"title": "Panel de Administración",
"users": "Gestión de Usuarios",
"settings": "Configuración del Sistema"
},
"home": {
"welcome": "Bienvenido a la Página de Inicio"
}
}

View File

@@ -0,0 +1,101 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createServerClient } from "@/lib/supabase/server";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
export async function logout() {
return await Sentry.withServerActionInstrumentation(
"logout",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
const { error } = await supabase.auth.signOut();
if (error) {
console.log("Error logging out", error);
return error.message;
}
revalidatePath("/", "layout");
redirect("/login");
},
);
}
export async function login(values: z.infer<typeof loginFormSchema>) {
return await Sentry.withServerActionInstrumentation("login", {}, async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signInWithPassword(values);
if (error) {
console.log("Error logging in", error);
if (error.status == 400) {
// Hence User is not present
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Logged in");
revalidatePath("/", "layout");
redirect("/");
});
}
export async function signup(values: z.infer<typeof loginFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
console.log("Error signing up", error);
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/store/profile");
},
);
}

View File

@@ -18,7 +18,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useSupabase } from "@/components/SupabaseProvider";
import { useSupabase } from "@/components/providers/SupabaseProvider";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
@@ -203,14 +203,34 @@ export default function LoginPage() {
className="flex w-full justify-center"
type="submit"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await login(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
Log in
{isLoading ? <FaSpinner className="animate-spin" /> : "Log in"}
</Button>
<Button
className="flex w-full justify-center"
type="button"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await signup(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{isLoading ? <FaSpinner className="animate-spin" /> : "Sign up"}
</Button>
</div>
<div className="w-full text-center">
<Link href={"/signup"} className="w-fit text-xs hover:underline">
Create a new Account
</Link>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>

View File

@@ -0,0 +1,10 @@
import { getDictionary } from "./dictionaries";
export default async function Page({
params: { lang },
}: {
params: { lang: string };
}) {
const dict = await getDictionary(lang); // en
return <h1>{dict.home.welcome}</h1>; // Add to Cart
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useSupabase } from "@/components/SupabaseProvider";
import { useSupabase } from "@/components/providers/SupabaseProvider";
import { Button } from "@/components/ui/button";
import useUser from "@/hooks/useUser";
import { useRouter } from "next/navigation";

View File

@@ -0,0 +1,161 @@
"use client";
import * as React from "react";
import { AgentTable } from "@/components/agptui/AgentTable";
import { AgentTableRowProps } from "@/components/agptui/AgentTableRow";
import { Button } from "@/components/agptui/Button";
import { Separator } from "@/components/ui/separator";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { createClient } from "@/lib/supabase/client";
import { StatusType } from "@/components/agptui/Status";
import { PublishAgentPopout } from "@/components/agptui/composite/PublishAgentPopout";
import { useCallback, useEffect, useState } from "react";
import {
StoreSubmissionsResponse,
StoreSubmissionRequest,
} from "@/lib/autogpt-server-api/types";
async function getDashboardData() {
const supabase = createClient();
if (!supabase) {
return { submissions: [] };
}
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
console.warn("--- No session found in profile page");
return { profile: null };
}
const api = new AutoGPTServerAPI(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase,
);
try {
const submissions = await api.getStoreSubmissions();
return {
submissions,
};
} catch (error) {
console.error("Error fetching profile:", error);
return {
profile: null,
};
}
}
export default function Page({
params: { lang },
}: {
params: { lang: string };
}) {
const [submissions, setSubmissions] = useState<StoreSubmissionsResponse>();
const [openPopout, setOpenPopout] = useState<boolean>(false);
const [submissionData, setSubmissionData] =
useState<StoreSubmissionRequest>();
const [popoutStep, setPopoutStep] = useState<"select" | "info" | "review">(
"info",
);
const fetchData = useCallback(async () => {
const { submissions } = await getDashboardData();
if (submissions) {
setSubmissions(submissions as StoreSubmissionsResponse);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => {
setSubmissionData(submission);
setPopoutStep("review");
setOpenPopout(true);
}, []);
const onDeleteSubmission = useCallback(
(submission_id: string) => {
const supabase = createClient();
if (!supabase) {
return;
}
const api = new AutoGPTServerAPI(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase,
);
api.deleteStoreSubmission(submission_id);
fetchData();
},
[fetchData],
);
const onOpenPopout = useCallback(() => {
setPopoutStep("select");
setOpenPopout(true);
}, []);
return (
<main className="flex-1 px-6 py-8 md:px-10">
{/* Header Section */}
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="font-neue text-3xl font-medium leading-9 tracking-tight text-neutral-900 dark:text-neutral-100">
Submit a New Agent
</h1>
<p className="mt-2 font-neue text-sm text-[#707070] dark:text-neutral-400">
Select from the list of agents you currently have, or upload from
your local machine.
</p>
</div>
<PublishAgentPopout
trigger={
<Button variant="default" size="lg" onClick={onOpenPopout}>
Create New Agent
</Button>
}
openPopout={openPopout}
inputStep={popoutStep}
submissionData={submissionData}
/>
</div>
<Separator className="mb-8" />
{/* Agents Section */}
<div>
<h2 className="mb-4 text-xl font-bold text-neutral-900 dark:text-neutral-100">
Your Agents
</h2>
<AgentTable
agents={
(submissions?.submissions.map((submission, index) => ({
id: index,
agent_id: submission.agent_id,
agent_version: submission.agent_version,
sub_heading: submission.sub_heading,
date_submitted: submission.date_submitted,
agentName: submission.name,
description: submission.description,
imageSrc: submission.image_urls || [""],
dateSubmitted: new Date(
submission.date_submitted,
).toLocaleDateString(),
status: submission.status.toLowerCase() as StatusType,
runs: submission.runs,
rating: submission.rating,
})) as AgentTableRowProps[]) || []
}
onEditSubmission={onEditSubmission}
onDeleteSubmission={onDeleteSubmission}
/>
</div>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { Sidebar } from "@/components/agptui/Sidebar";
export default function Layout({ children }: { children: React.ReactNode }) {
const sidebarLinkGroups = [
{
links: [
{ text: "Creator Dashboard", href: "/store/dashboard" },
{ text: "Agent dashboard", href: "/store/agent-dashboard" },
{ text: "Integrations", href: "/store/integrations" },
{ text: "Profile", href: "/store/profile" },
{ text: "Settings", href: "/store/settings" },
],
},
];
return (
<div className="flex min-h-screen w-screen max-w-[1360px] flex-col lg:flex-row">
<Sidebar linkGroups={sidebarLinkGroups} />
{children}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { ProfileInfoForm } from "@/components/agptui/ProfileInfoForm";
import AutoGPTServerAPIServerSide from "@/lib/autogpt-server-api";
import { createServerClient } from "@/lib/supabase/server";
import { CreatorDetails } from "@/lib/autogpt-server-api/types";
async function getProfileData() {
// Get the supabase client first
const supabase = createServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
console.warn("--- No session found in profile page");
return { profile: null };
}
// Create API client with the same supabase instance
const api = new AutoGPTServerAPIServerSide(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase, // Pass the supabase client instance
);
try {
const profile = await api.getStoreProfile("profile");
return {
profile,
};
} catch (error) {
console.error("Error fetching profile:", error);
return {
profile: null,
};
}
}
export default async function Page({
params: { lang },
}: {
params: { lang: string };
}) {
const { profile } = await getProfileData();
if (!profile) {
return (
<div className="flex flex-col items-center justify-center p-4">
<p>Please log in to view your profile</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center px-4">
<ProfileInfoForm profile={profile as CreatorDetails} />
</div>
);
}

View File

@@ -0,0 +1,6 @@
import * as React from "react";
import { SettingsInputForm } from "@/components/agptui/SettingsInputForm";
export default function Page() {
return <SettingsInputForm />;
}

View File

@@ -0,0 +1,97 @@
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { Navbar } from "@/components/agptui/Navbar";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { AgentInfo } from "@/components/agptui/AgentInfo";
import { AgentImages } from "@/components/agptui/AgentImages";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BecomeACreator } from "@/components/agptui/BecomeACreator";
import { Separator } from "@/components/ui/separator";
import { Metadata } from "next";
import { RatingCard } from "@/components/agptui/RatingCard";
export async function generateMetadata({
params,
}: {
params: { creator: string; slug: string };
}): Promise<Metadata> {
const api = new AutoGPTServerAPI();
const agent = await api.getStoreAgent(params.creator, params.slug);
return {
title: `${agent.agent_name} - AutoGPT Store`,
description: agent.description,
};
}
export async function generateStaticParams() {
const api = new AutoGPTServerAPI();
const agents = await api.getStoreAgents({ featured: true });
return agents.agents.map((agent) => ({
creator: agent.creator,
slug: agent.slug,
lang: "en",
}));
}
export default async function Page({
params,
}: {
params: { lang: string; creator: string; slug: string };
}) {
const api = new AutoGPTServerAPI();
const agent = await api.getStoreAgent(params.creator, params.slug);
const otherAgents = await api.getStoreAgents({ creator: params.creator });
const similarAgents = await api.getStoreAgents({
search_query: agent.categories[0],
});
const breadcrumbs = [
{ name: "Store", link: "/store" },
{ name: agent.creator, link: `/store/creator/${agent.creator}` },
{ name: agent.agent_name, link: "#" },
];
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4 md:mt-4 lg:mt-8">
<BreadCrumbs items={breadcrumbs} />
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<AgentInfo
name={agent.agent_name}
creator={agent.creator}
shortDescription={agent.description}
longDescription={agent.description}
rating={agent.rating}
runs={agent.runs}
categories={agent.categories}
lastUpdated={agent.updated_at}
version={agent.versions[agent.versions.length - 1]}
/>
</div>
<AgentImages images={agent.agent_image} />
</div>
<Separator className="my-6" />
<AgentsSection
agents={otherAgents.agents}
sectionTitle={`Other agents by ${agent.creator}`}
/>
<Separator className="my-6" />
<AgentsSection
agents={similarAgents.agents}
sectionTitle="Similar agents"
/>
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
<div className="fixed bottom-8 right-8">
<RatingCard agentName={agent.agent_name} />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import {
CreatorDetails as Creator,
StoreAgent,
} from "@/lib/autogpt-server-api";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { Metadata } from "next";
import { CreatorInfoCard } from "@/components/agptui/CreatorInfoCard";
import { CreatorLinks } from "@/components/agptui/CreatorLinks";
export async function generateMetadata({
params,
}: {
params: { creator: string };
}): Promise<Metadata> {
const api = new AutoGPTServerAPI();
const creator = await api.getStoreCreator(params.creator);
return {
title: `${creator.name} - AutoGPT Store`,
description: creator.description,
};
}
export async function generateStaticParams() {
const api = new AutoGPTServerAPI();
const creators = await api.getStoreCreators({ featured: true });
return creators.creators.map((creator) => ({
creator: creator.username,
lang: "en",
}));
}
export default async function Page({
params,
}: {
params: { lang: string; creator: string };
}) {
const api = new AutoGPTServerAPI();
try {
const creator = await api.getStoreCreator(params.creator);
const creatorAgents = await api.getStoreAgents({ creator: params.creator });
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4 md:mt-4 lg:mt-8">
<BreadCrumbs
items={[
{ name: "Store", link: "/store" },
{ name: creator.name, link: "#" },
]}
/>
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<CreatorInfoCard
username={creator.name}
handle={creator.username}
avatarSrc={creator.avatar_url}
categories={creator.top_categories}
averageRating={creator.agent_rating}
totalRuns={creator.agent_runs}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8">
<div className="font-neue text-2xl font-normal leading-normal text-neutral-900 sm:text-3xl md:text-[35px] md:leading-[45px]">
{creator.description}
</div>
<CreatorLinks links={creator.links} />
</div>
</div>
<div className="mt-8 sm:mt-12 md:mt-16">
<hr className="w-full bg-neutral-700" />
<AgentsSection
agents={creatorAgents.agents}
hideAvatars={true}
sectionTitle={`Agents by ${creator.name}`}
/>
</div>
</main>
</div>
);
} catch (error) {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="font-neue text-2xl text-neutral-900">
Creator not found
</div>
</div>
);
}
}

View File

@@ -0,0 +1,123 @@
import * as React from "react";
import { HeroSection } from "@/components/agptui/composite/HeroSection";
import {
FeaturedSection,
FeaturedAgent,
} from "@/components/agptui/composite/FeaturedSection";
import {
AgentsSection,
Agent,
} from "@/components/agptui/composite/AgentsSection";
import { BecomeACreator } from "@/components/agptui/BecomeACreator";
import {
FeaturedCreators,
FeaturedCreator,
} from "@/components/agptui/composite/FeaturedCreators";
import { Separator } from "@/components/ui/separator";
import AutoGPTServerAPIServerSide from "@/lib/autogpt-server-api";
import { Metadata } from "next";
import { createServerClient } from "@/lib/supabase/server";
async function getStoreData() {
const supabase = createServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
const api = new AutoGPTServerAPIServerSide(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase,
);
const [featuredAgents, topAgents, featuredCreators] = await Promise.all([
api.getStoreAgents({ featured: true }),
api.getStoreAgents({ sorted_by: "runs" }),
api.getStoreCreators({ featured: true, sorted_by: "num_agents" }),
]);
return {
featuredAgents,
topAgents,
featuredCreators,
};
}
// FIX: Correct metadata
export const metadata: Metadata = {
title: "Agent Store - NextGen AutoGPT",
description: "Find and use AI Agents created by our community",
applicationName: "NextGen AutoGPT Store",
authors: [{ name: "AutoGPT Team" }],
keywords: [
"AI agents",
"automation",
"artificial intelligence",
"AutoGPT",
"marketplace",
],
robots: {
index: true,
follow: true,
},
openGraph: {
title: "Agent Store - NextGen AutoGPT",
description: "Find and use AI Agents created by our community",
type: "website",
siteName: "NextGen AutoGPT Store",
images: [
{
url: "/images/store-og.png",
width: 1200,
height: 630,
alt: "NextGen AutoGPT Store",
},
],
},
twitter: {
card: "summary_large_image",
title: "Agent Store - NextGen AutoGPT",
description: "Find and use AI Agents created by our community",
images: ["/images/store-twitter.png"],
},
icons: {
icon: "/favicon.ico",
shortcut: "/favicon-16x16.png",
apple: "/apple-touch-icon.png",
},
};
export default async function Page({
params: { lang },
}: {
params: { lang: string };
}) {
// Get data server-side
const { featuredAgents, topAgents, featuredCreators } = await getStoreData();
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4">
<HeroSection />
<FeaturedSection
featuredAgents={featuredAgents.agents as FeaturedAgent[]}
/>
<Separator />
<AgentsSection
sectionTitle="Top Agents"
agents={topAgents.agents as Agent[]}
/>
<Separator />
<FeaturedCreators
featuredCreators={featuredCreators.creators as FeaturedCreator[]}
/>
<Separator />
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
</main>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useState, useEffect } from "react";
import AutoGPTServerAPIClient from "@/lib/autogpt-server-api/client";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { SearchBar } from "@/components/agptui/SearchBar";
import { FeaturedCreators } from "@/components/agptui/composite/FeaturedCreators";
import { Separator } from "@/components/ui/separator";
import { SearchFilterChips } from "@/components/agptui/SearchFilterChips";
import { SortDropdown } from "@/components/agptui/SortDropdown";
export default function Page({
params,
searchParams,
}: {
params: { lang: string };
searchParams: { searchTerm?: string; sort?: string };
}) {
return (
<SearchResults
searchTerm={searchParams.searchTerm || ""}
sort={searchParams.sort || "trending"}
/>
);
}
function SearchResults({
searchTerm,
sort,
}: {
searchTerm: string;
sort: string;
}) {
const [showAgents, setShowAgents] = useState(true);
const [showCreators, setShowCreators] = useState(true);
const [agents, setAgents] = useState<any[]>([]);
const [creators, setCreators] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
const api = new AutoGPTServerAPIClient();
try {
const [agentsRes, creatorsRes] = await Promise.all([
api.getStoreAgents({
search_query: searchTerm,
sorted_by: sort,
}),
api.getStoreCreators({
search_query: searchTerm,
}),
]);
setAgents(agentsRes.agents || []);
setCreators(creatorsRes.creators || []);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [searchTerm, sort]);
const agentsCount = agents.length;
const creatorsCount = creators.length;
const totalCount = agentsCount + creatorsCount;
const handleFilterChange = (value: string) => {
if (value === "agents") {
setShowAgents(true);
setShowCreators(false);
} else if (value === "creators") {
setShowAgents(false);
setShowCreators(true);
} else {
setShowAgents(true);
setShowCreators(true);
}
};
const handleSortChange = (sortValue: string) => {
let sortBy = "recent";
if (sortValue === "runs") {
sortBy = "runs";
} else if (sortValue === "rating") {
sortBy = "rating";
}
const sortedAgents = [...agents].sort((a, b) => {
if (sortBy === "runs") {
return b.runs - a.runs;
} else if (sortBy === "rating") {
return b.rating - a.rating;
} else {
return (
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
}
});
const sortedCreators = [...creators].sort((a, b) => {
if (sortBy === "runs") {
return b.agent_runs - a.agent_runs;
} else if (sortBy === "rating") {
return b.agent_rating - a.agent_rating;
} else {
// Creators don't have updated_at, sort by number of agents as fallback
return b.num_agents - a.num_agents;
}
});
setAgents(sortedAgents);
setCreators(sortedCreators);
};
return (
<div className="w-full">
<div className="mx-auto min-h-screen max-w-[1440px] px-10 lg:min-w-[1440px]">
<div className="mt-8 flex items-center">
<div className="flex-1">
<h2 className="font-['Poppins'] text-base font-medium text-neutral-800 dark:text-neutral-200">
Results for:
</h2>
<h1 className="font-['Poppins'] text-2xl font-semibold text-neutral-800 dark:text-neutral-100">
{searchTerm}
</h1>
</div>
<div className="flex-none">
<SearchBar width="w-[439px]" />
</div>
</div>
{isLoading ? (
<div className="mt-20 flex flex-col items-center justify-center">
<p className="text-neutral-500 dark:text-neutral-400">Loading...</p>
</div>
) : totalCount > 0 ? (
<>
<div className="mt-8 flex items-center justify-between">
<SearchFilterChips
totalCount={totalCount}
agentsCount={agentsCount}
creatorsCount={creatorsCount}
onFilterChange={handleFilterChange}
/>
<SortDropdown onSort={handleSortChange} />
</div>
{/* Content section */}
<div className="min-h-[500px] max-w-[1440px]">
{showAgents && agentsCount > 0 && (
<div className="mt-8">
<AgentsSection agents={agents} sectionTitle="Agents" />
</div>
)}
{showAgents && agentsCount > 0 && creatorsCount > 0 && (
<Separator />
)}
{showCreators && creatorsCount > 0 && (
<FeaturedCreators
featuredCreators={creators}
title="Creators"
/>
)}
</div>
</>
) : (
<div className="mt-20 flex flex-col items-center justify-center">
<h3 className="mb-2 text-xl font-medium text-neutral-600 dark:text-neutral-300">
No results found
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
Try adjusting your search terms or filters
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,6 +2,55 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "PP Neue Montreal TT";
src:
url("/fonts/PPNeueMontreal-Regular.woff2") format("woff2"),
url("/fonts/PPNeueMontreal-Regular.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@layer base {
.font-neue {
font-family: "PP Neue Montreal TT", sans-serif;
}
}
@layer utilities {
.w-110 {
width: 27.5rem;
}
.h-7\.5 {
height: 1.1875rem;
}
.h-18 {
height: 4.5rem;
}
.h-238 {
height: 14.875rem;
}
.top-158 {
top: 9.875rem;
}
.top-254 {
top: 15.875rem;
}
.top-284 {
top: 17.75rem;
}
.top-360 {
top: 22.5rem;
}
.left-297 {
left: 18.5625rem;
}
.left-34 {
left: 2.125rem;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;

View File

@@ -2,13 +2,19 @@ import React from "react";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Providers } from "@/app/providers";
import { NavBar } from "@/components/NavBar";
import { cn } from "@/lib/utils";
import { Navbar } from "@/components/agptui/Navbar";
import "./globals.css";
import TallyPopupSimple from "@/components/TallyPopup";
import { GoogleAnalytics } from "@next/third-parties/google";
import { Toaster } from "@/components/ui/toaster";
import { IconType } from "@/components/ui/icons";
import { createServerClient } from "@/lib/supabase/server";
// Import Fonts
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
const inter = Inter({ subsets: ["latin"] });
@@ -17,23 +23,88 @@ export const metadata: Metadata = {
description: "Your one stop shop to creating AI Agents",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = createServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<html lang="en">
<body className={cn("antialiased transition-colors", inter.className)}>
<Providers
initialUser={user}
attribute="class"
defaultTheme="light"
// Feel free to remove this line if you want to use the system theme by default
// enableSystem
disableTransitionOnChange
>
<div className="flex min-h-screen flex-col">
<NavBar />
<div className="flex min-h-screen flex-col items-center justify-center">
<Navbar
user={user}
isLoggedIn={!!user}
links={[
{
name: "Agent Store",
href: "/store",
},
{
name: "Library",
href: "/library",
},
{
name: "Build",
href: "/build",
},
]}
menuItemGroups={[
{
items: [
{
icon: IconType.Edit,
text: "Edit profile",
href: "/store/profile",
},
],
},
{
items: [
{
icon: IconType.LayoutDashboard,
text: "Creator Dashboard",
href: "/store/dashboard",
},
{
icon: IconType.UploadCloud,
text: "Publish an agent",
},
],
},
{
items: [
{
icon: IconType.Settings,
text: "Settings",
href: "/store/settings",
},
],
},
{
items: [
{
icon: IconType.LogOut,
text: "Log out",
},
],
},
]}
/>
<main className="flex-1 p-4">{children}</main>
<TallyPopupSimple />
</div>

View File

@@ -1,40 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createServerClient } from "@/lib/supabase/server";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
export async function login(values: z.infer<typeof loginFormSchema>) {
return await Sentry.withServerActionInstrumentation("login", {}, async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signInWithPassword(values);
if (error) {
if (error.status == 400) {
// Hence User is not present
redirect("/signup");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/", "layout");
redirect("/");
});
}

View File

@@ -1,41 +0,0 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import MarketplaceAPI from "@/lib/marketplace-api";
import { AgentDetailResponse } from "@/lib/marketplace-api";
import AgentDetailContent from "@/components/marketplace/AgentDetailContent";
async function getAgentDetails(id: string): Promise<AgentDetailResponse> {
const apiUrl =
process.env.NEXT_PUBLIC_AGPT_MARKETPLACE_URL ||
"http://localhost:8015/api/v1/market";
const api = new MarketplaceAPI(apiUrl);
try {
console.log(`Fetching agent details for id: ${id}`);
const agent = await api.getAgentDetails(id);
console.log(`Agent details fetched:`, agent);
return agent;
} catch (error) {
console.error(`Error fetching agent details:`, error);
throw error;
}
}
export default async function AgentDetailPage({
params,
}: {
params: { id: string };
}) {
let agent: AgentDetailResponse;
try {
agent = await getAgentDetails(params.id);
} catch (error) {
return notFound();
}
return (
<Suspense fallback={<div>Loading...</div>}>
<AgentDetailContent agent={agent} />
</Suspense>
);
}

View File

@@ -1,352 +0,0 @@
"use client";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import MarketplaceAPI, {
AgentResponse,
AgentWithRank,
} from "@/lib/marketplace-api";
import {
ChevronLeft,
ChevronRight,
PlusCircle,
Search,
Star,
} from "lucide-react";
// Utility Functions
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Types
type Agent = AgentResponse | AgentWithRank;
// Components
const HeroSection: React.FC = () => {
const router = useRouter();
return (
<div className="relative bg-indigo-600 py-6">
<div className="absolute inset-0 z-0">
<Image
src="https://images.unsplash.com/photo-1562408590-e32931084e23?auto=format&fit=crop&w=2070&q=80"
alt="Marketplace background"
layout="fill"
objectFit="cover"
quality={75}
priority
className="opacity-20"
/>
<div
className="absolute inset-0 bg-indigo-600 mix-blend-multiply"
aria-hidden="true"
></div>
</div>
<div className="relative mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-white sm:text-3xl lg:text-4xl">
AutoGPT Marketplace
</h1>
<p className="mt-2 max-w-3xl text-sm text-indigo-100 sm:text-base">
Discover and share proven AI Agents to supercharge your business.
</p>
</div>
<Button
onClick={() => router.push("/marketplace/submit")}
className="flex items-center bg-white text-indigo-600 hover:bg-indigo-50"
>
<PlusCircle className="mr-2 h-4 w-4" />
Submit Agent
</Button>
</div>
</div>
);
};
const SearchInput: React.FC<{
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}> = ({ value, onChange }) => (
<div className="relative mb-8">
<Input
placeholder="Search agents..."
type="text"
className="w-full rounded-full border-gray-300 py-2 pl-10 pr-4 focus:border-indigo-500 focus:ring-indigo-500"
value={value}
onChange={onChange}
/>
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400"
size={20}
/>
</div>
);
const AgentCard: React.FC<{ agent: Agent; featured?: boolean }> = ({
agent,
featured = false,
}) => {
const router = useRouter();
const handleClick = () => {
router.push(`/marketplace/${agent.id}`);
};
return (
<div
className={`flex cursor-pointer flex-col justify-between rounded-lg border p-6 transition-colors duration-200 hover:bg-gray-50 ${featured ? "border-indigo-500 shadow-md" : "border-gray-300"}`}
onClick={handleClick}
>
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="truncate text-lg font-semibold text-gray-900">
{agent.name}
</h3>
{featured && <Star className="text-indigo-500" size={20} />}
</div>
<p className="mb-4 line-clamp-2 text-sm text-gray-500">
{agent.description}
</p>
<div className="mb-2 text-xs text-gray-400">
Categories: {agent.categories?.join(", ")}
</div>
</div>
<div className="flex items-end justify-between">
<div className="text-xs text-gray-400">
Updated {new Date(agent.updatedAt).toLocaleDateString()}
</div>
<div className="text-xs text-gray-400">Downloads {agent.downloads}</div>
{"rank" in agent && (
<div className="text-xs text-indigo-600">
Rank: {agent.rank.toFixed(2)}
</div>
)}
</div>
</div>
);
};
const AgentGrid: React.FC<{
agents: Agent[];
title: string;
featured?: boolean;
}> = ({ agents, title, featured = false }) => (
<div className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-gray-900">{title}</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<AgentCard agent={agent} key={agent.id} featured={featured} />
))}
</div>
</div>
);
const Pagination: React.FC<{
page: number;
totalPages: number;
onPrevPage: () => void;
onNextPage: () => void;
}> = ({ page, totalPages, onPrevPage, onNextPage }) => (
<div className="mt-8 flex items-center justify-between">
<Button
onClick={onPrevPage}
disabled={page === 1}
className="flex items-center space-x-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
<ChevronLeft size={16} />
<span>Previous</span>
</Button>
<span className="text-sm text-gray-700">
Page {page} of {totalPages}
</span>
<Button
onClick={onNextPage}
disabled={page === totalPages}
className="flex items-center space-x-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
<span>Next</span>
<ChevronRight size={16} />
</Button>
</div>
);
// Main Component
const Marketplace: React.FC = () => {
const apiUrl =
process.env.NEXT_PUBLIC_AGPT_MARKETPLACE_URL ||
"http://localhost:8015/api/v1/market";
const api = useMemo(() => new MarketplaceAPI(apiUrl), [apiUrl]);
const [searchValue, setSearchValue] = useState("");
const [searchResults, setSearchResults] = useState<Agent[]>([]);
const [featuredAgents, setFeaturedAgents] = useState<Agent[]>([]);
const [topAgents, setTopAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [topAgentsPage, setTopAgentsPage] = useState(1);
const [searchPage, setSearchPage] = useState(1);
const [topAgentsTotalPages, setTopAgentsTotalPages] = useState(1);
const [searchTotalPages, setSearchTotalPages] = useState(1);
const fetchTopAgents = useCallback(
async (currentPage: number) => {
setIsLoading(true);
try {
const response = await api.getTopDownloadedAgents(currentPage, 9);
setTopAgents(response.items);
setTopAgentsTotalPages(response.total_pages);
} catch (error) {
console.error("Error fetching top agents:", error);
} finally {
setIsLoading(false);
}
},
[api],
);
const fetchFeaturedAgents = useCallback(async () => {
try {
const featured = await api.getFeaturedAgents();
setFeaturedAgents(featured.items);
} catch (error) {
console.error("Error fetching featured agents:", error);
}
}, [api]);
const searchAgents = useCallback(
async (searchTerm: string, currentPage: number) => {
setIsLoading(true);
try {
const response = await api.searchAgents(searchTerm, currentPage, 9);
const filteredAgents = response.items.filter((agent) => agent.rank > 0);
setSearchResults(filteredAgents);
setSearchTotalPages(response.total_pages);
} catch (error) {
console.error("Error searching agents:", error);
} finally {
setIsLoading(false);
}
},
[api],
);
const debouncedSearch = useMemo(
() => debounce(searchAgents, 300),
[searchAgents],
);
useEffect(() => {
if (searchValue) {
searchAgents(searchValue, searchPage);
} else {
fetchTopAgents(topAgentsPage);
}
}, [searchValue, searchPage, topAgentsPage, searchAgents, fetchTopAgents]);
useEffect(() => {
fetchFeaturedAgents();
}, [fetchFeaturedAgents]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
setSearchPage(1);
};
const handleNextPage = () => {
if (searchValue) {
if (searchPage < searchTotalPages) {
setSearchPage(searchPage + 1);
}
} else {
if (topAgentsPage < topAgentsTotalPages) {
setTopAgentsPage(topAgentsPage + 1);
}
}
};
const handlePrevPage = () => {
if (searchValue) {
if (searchPage > 1) {
setSearchPage(searchPage - 1);
}
} else {
if (topAgentsPage > 1) {
setTopAgentsPage(topAgentsPage - 1);
}
}
};
return (
<div className="min-h-screen bg-gray-50">
<HeroSection />
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<SearchInput value={searchValue} onChange={handleInputChange} />
{isLoading ? (
<div className="py-12 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
<p className="mt-2 text-gray-600">Loading agents...</p>
</div>
) : searchValue ? (
searchResults.length > 0 ? (
<>
<AgentGrid agents={searchResults} title="Search Results" />
<Pagination
page={searchPage}
totalPages={searchTotalPages}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
/>
</>
) : (
<div className="py-12 text-center">
<p className="text-gray-600">
No agents found matching your search criteria.
</p>
</div>
)
) : (
<>
{featuredAgents?.length > 0 ? (
<AgentGrid
agents={featuredAgents}
title="Featured Agents"
featured={true}
/>
) : (
<div className="py-12 text-center">
<p className="text-gray-600">No Featured Agents found</p>
</div>
)}
<hr />
{topAgents?.length > 0 ? (
<AgentGrid agents={topAgents} title="Top Downloaded Agents" />
) : (
<div className="py-12 text-center">
<p className="text-gray-600">No Top Downloaded Agents found</p>
</div>
)}
<Pagination
page={topAgentsPage}
totalPages={topAgentsTotalPages}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
/>
</>
)}
</div>
</div>
);
};
export default Marketplace;

View File

@@ -1,453 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import MarketplaceAPI from "@/lib/marketplace-api";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from "@/components/ui/multiselect";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type FormData = {
name: string;
description: string;
author: string;
keywords: string[];
categories: string[];
agreeToTerms: boolean;
selectedAgentId: string;
};
const keywords = [
"Automation",
"AI Workflows",
"Integration",
"Task Automation",
"Data Processing",
"Workflow Management",
"Real-time Analytics",
"Custom Triggers",
"Event-driven",
"API Integration",
"Data Transformation",
"Multi-step Workflows",
"Collaboration Tools",
"Business Process Automation",
"No-code Solutions",
"AI-Powered",
"Smart Notifications",
"Data Syncing",
"User Engagement",
"Reporting Automation",
"Lead Generation",
"Customer Support Automation",
"E-commerce Automation",
"Social Media Management",
"Email Marketing Automation",
"Document Management",
"Data Enrichment",
"Performance Tracking",
"Predictive Analytics",
"Resource Allocation",
"Chatbot",
"Virtual Assistant",
"Workflow Automation",
"Social Media Manager",
"Email Optimizer",
"Content Generator",
"Data Analyzer",
"Task Scheduler",
"Customer Service Bot",
"Personalization Engine",
];
const SubmitPage: React.FC = () => {
const router = useRouter();
const {
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
defaultValues: {
selectedAgentId: "", // Initialize with an empty string
name: "",
description: "",
author: "",
keywords: [],
categories: [],
agreeToTerms: false,
},
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [userAgents, setUserAgents] = useState<
Array<{ id: string; name: string; version: number }>
>([]);
const [selectedAgentGraph, setSelectedAgentGraph] = useState<any>(null);
const selectedAgentId = watch("selectedAgentId");
useEffect(() => {
const fetchUserAgents = async () => {
const api = new AutoGPTServerAPI();
const agents = await api.listGraphs();
console.log(agents);
setUserAgents(
agents.map((agent) => ({
id: agent.id,
name: agent.name || `Agent (${agent.id})`,
version: agent.version,
})),
);
};
fetchUserAgents();
}, []);
useEffect(() => {
const fetchAgentGraph = async () => {
if (selectedAgentId) {
const api = new AutoGPTServerAPI();
const graph = await api.getGraph(selectedAgentId, undefined, true);
setSelectedAgentGraph(graph);
setValue("name", graph.name);
setValue("description", graph.description);
}
};
fetchAgentGraph();
}, [selectedAgentId, setValue]);
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
setSubmitError(null);
if (!data.agreeToTerms) {
throw new Error("You must agree to the terms of use");
}
try {
if (!selectedAgentGraph) {
throw new Error("Please select an agent");
}
const api = new MarketplaceAPI();
await api.submitAgent(
{
...selectedAgentGraph,
name: data.name,
description: data.description,
},
data.author,
data.keywords,
data.categories,
);
router.push("/marketplace?submission=success");
} catch (error) {
console.error("Submission error:", error);
setSubmitError(
error instanceof Error ? error.message : "An unknown error occurred",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">Submit Your Agent</h1>
<Card className="p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<Controller
name="selectedAgentId"
control={control}
rules={{ required: "Please select an agent" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Select Agent
</label>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{userAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name} (v{agent.version})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.selectedAgentId && (
<p className="mt-1 text-sm text-red-600">
{errors.selectedAgentId.message}
</p>
)}
</div>
)}
/>
{/* {selectedAgentGraph && (
<div className="mt-4" style={{ height: "600px" }}>
<ReactFlow
nodes={nodes}
edges={edges}
fitView
attributionPosition="bottom-left"
nodesConnectable={false}
nodesDraggable={false}
zoomOnScroll={false}
panOnScroll={false}
elementsSelectable={false}
>
<Controls showInteractive={false} />
<Background />
</ReactFlow>
</div>
)} */}
<Controller
name="name"
control={control}
rules={{ required: "Name is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Agent Name
</label>
<Input
id={field.name}
placeholder="Enter your agent's name"
{...field}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
)}
/>
<Controller
name="description"
control={control}
rules={{ required: "Description is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Description
</label>
<Textarea
id={field.name}
placeholder="Describe your agent"
{...field}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">
{errors.description.message}
</p>
)}
</div>
)}
/>
<Controller
name="author"
control={control}
rules={{ required: "Author is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Author
</label>
<Input
id={field.name}
placeholder="Your name or username"
{...field}
/>
{errors.author && (
<p className="mt-1 text-sm text-red-600">
{errors.author.message}
</p>
)}
</div>
)}
/>
<Controller
name="keywords"
control={control}
rules={{ required: "At least one keyword is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Keywords
</label>
<MultiSelector
values={field.value || []}
onValuesChange={field.onChange}
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Add keywords" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{keywords.map((keyword) => (
<MultiSelectorItem key={keyword} value={keyword}>
{keyword}
</MultiSelectorItem>
))}
{/* Add more predefined keywords as needed */}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
{errors.keywords && (
<p className="mt-1 text-sm text-red-600">
{errors.keywords.message}
</p>
)}
</div>
)}
/>
<Controller
name="categories"
control={control}
rules={{ required: "At least one category is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Categories
</label>
<MultiSelector
values={field.value || []}
onValuesChange={field.onChange}
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select categories" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="productivity">
Productivity
</MultiSelectorItem>
<MultiSelectorItem value="entertainment">
Entertainment
</MultiSelectorItem>
<MultiSelectorItem value="education">
Education
</MultiSelectorItem>
<MultiSelectorItem value="business">
Business
</MultiSelectorItem>
<MultiSelectorItem value="other">
Other
</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
{errors.categories && (
<p className="mt-1 text-sm text-red-600">
{errors.categories.message}
</p>
)}
</div>
)}
/>
<Controller
name="agreeToTerms"
control={control}
rules={{ required: "You must agree to the terms of use" }}
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="agreeToTerms"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="agreeToTerms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I agree to the{" "}
<a
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="text-blue-500 hover:underline"
>
terms of use
</a>
</label>
</div>
)}
/>
{errors.agreeToTerms && (
<p className="mt-1 text-sm text-red-600">
{errors.agreeToTerms.message}
</p>
)}
{submitError && (
<Alert variant="destructive">
<AlertTitle>Submission Failed</AlertTitle>
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Agent"}
</Button>
</div>
</form>
</Card>
</div>
);
};
export default SubmitPage;

View File

@@ -2,18 +2,23 @@
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
import { ThemeProviderProps } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import SupabaseProvider from "@/components/SupabaseProvider";
import SupabaseProvider from "@/components/providers/SupabaseProvider";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import { User } from "@supabase/supabase-js";
export function Providers({ children, ...props }: ThemeProviderProps) {
export function Providers({
children,
initialUser,
...props
}: ThemeProviderProps & { initialUser: User | null }) {
return (
<NextThemesProvider {...props}>
<SupabaseProvider>
<CredentialsProvider>
<TooltipProvider>{children}</TooltipProvider>
</CredentialsProvider>
<SupabaseProvider initialUser={initialUser}>
{/* <CredentialsProvider> */}
<TooltipProvider>{children}</TooltipProvider>
{/* </CredentialsProvider> */}
</SupabaseProvider>
</NextThemesProvider>
);

View File

@@ -1,46 +0,0 @@
"use server";
import { createServerClient } from "@/lib/supabase/server";
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const SignupFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
export async function signup(values: z.infer<typeof SignupFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/", "layout");
redirect("/");
},
);
}

View File

@@ -1,225 +0,0 @@
"use client";
import useUser from "@/hooks/useUser";
import { signup } from "./actions";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useSupabase } from "@/components/SupabaseProvider";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
const signupFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
});
export default function LoginPage() {
const { supabase, isLoading: isSupabaseLoading } = useSupabase();
const { user, isLoading: isUserLoading } = useUser();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
email: "",
password: "",
agreeToTerms: false,
},
});
if (user) {
console.log("User exists, redirecting to home");
router.push("/");
}
if (isUserLoading || isSupabaseLoading || user) {
return (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
</div>
);
}
if (!supabase) {
return (
<div>
User accounts are disabled because Supabase client is unavailable
</div>
);
}
async function handleSignInWithProvider(
provider: "google" | "github" | "discord",
) {
const { data, error } = await supabase!.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
if (!error) {
setFeedback(null);
return;
}
setFeedback(error.message);
}
const onSignup = async (data: z.infer<typeof signupFormSchema>) => {
if (await form.trigger()) {
setIsLoading(true);
const error = await signup(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
}
};
return (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">Create a New Account</h1>
{/* <div className="mb-6 space-y-2">
<Button
className="w-full"
onClick={() => handleSignInWithProvider("google")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGoogle className="mr-2 h-4 w-4" />
Sign in with Google
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("github")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
Sign in with GitHub
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("discord")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaDiscord className="mr-2 h-4 w-4" />
Sign in with Discord
</Button>
</div> */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormDescription>
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the{" "}
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-full justify-center"
variant="outline"
type="button"
onClick={form.handleSubmit(onSignup)}
disabled={isLoading}
>
Sign up
</Button>
</div>
<div className="w-full text-center">
<Link href={"/login"} className="w-fit text-xs hover:underline">
Already a member? Log In here
</Link>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>
</Form>
</div>
</div>
);
}

View File

@@ -232,7 +232,7 @@ export function CustomNode({
) : (
propKey != "credentials" && (
<div className="flex gap-1">
<span className="text-m green mb-0 text-gray-900">
<span className="text-m green mb-0 text-gray-900 dark:text-gray-100">
{propSchema.title || beautifyString(propKey)}
</span>
<SchemaTooltip description={propSchema.description} />
@@ -434,49 +434,54 @@ export function CustomNode({
"custom-node",
"dark-theme",
"rounded-xl",
"bg-white/[.9]",
"border border-gray-300",
"bg-white/[.9] dark:bg-gray-800/[.9]",
"border border-gray-300 dark:border-gray-600",
data.uiType === BlockUIType.NOTE ? "w-[300px]" : "w-[500px]",
data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white",
data.uiType === BlockUIType.NOTE
? "bg-yellow-100 dark:bg-yellow-900"
: "bg-white dark:bg-gray-800",
selected ? "shadow-2xl" : "",
]
.filter(Boolean)
.join(" ");
const errorClass =
hasConfigErrors || hasOutputError ? "border-red-200 border-2" : "";
hasConfigErrors || hasOutputError
? "border-red-200 dark:border-red-800 border-2"
: "";
const statusClass = (() => {
if (hasConfigErrors || hasOutputError) return "border-red-200 border-4";
if (hasConfigErrors || hasOutputError)
return "border-red-200 dark:border-red-800 border-4";
switch (data.status?.toLowerCase()) {
case "completed":
return "border-green-200 border-4";
return "border-green-200 dark:border-green-800 border-4";
case "running":
return "border-yellow-200 border-4";
return "border-yellow-200 dark:border-yellow-800 border-4";
case "failed":
return "border-red-200 border-4";
return "border-red-200 dark:border-red-800 border-4";
case "incomplete":
return "border-purple-200 border-4";
return "border-purple-200 dark:border-purple-800 border-4";
case "queued":
return "border-cyan-200 border-4";
return "border-cyan-200 dark:border-cyan-800 border-4";
default:
return "";
}
})();
const statusBackgroundClass = (() => {
if (hasConfigErrors || hasOutputError) return "bg-red-200";
if (hasConfigErrors || hasOutputError) return "bg-red-200 dark:bg-red-800";
switch (data.status?.toLowerCase()) {
case "completed":
return "bg-green-200";
return "bg-green-200 dark:bg-green-800";
case "running":
return "bg-yellow-200";
return "bg-yellow-200 dark:bg-yellow-800";
case "failed":
return "bg-red-200";
return "bg-red-200 dark:bg-red-800";
case "incomplete":
return "bg-purple-200";
return "bg-purple-200 dark:bg-purple-800";
case "queued":
return "bg-cyan-200";
return "bg-cyan-200 dark:bg-cyan-800";
default:
return "";
}
@@ -514,36 +519,36 @@ export function CustomNode({
);
const LineSeparator = () => (
<div className="bg-white pt-6">
<Separator.Root className="h-[1px] w-full bg-gray-300"></Separator.Root>
<div className="bg-white pt-6 dark:bg-gray-800">
<Separator.Root className="h-[1px] w-full bg-gray-300 dark:bg-gray-600"></Separator.Root>
</div>
);
const ContextMenuContent = () => (
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md">
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item
onSelect={copyNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100"
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<CopyIcon className="mr-2 h-5 w-5" />
<span>Copy</span>
<CopyIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</ContextMenu.Item>
{nodeFlowId && (
<ContextMenu.Item
onSelect={() => window.open(`/build?flowID=${nodeFlowId}`)}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100"
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ExitIcon className="mr-2 h-5 w-5" />
<span>Open agent</span>
<ExitIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Open agent</span>
</ContextMenu.Item>
)}
<ContextMenu.Separator className="my-1 h-px bg-gray-300" />
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
onSelect={deleteNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100"
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TrashIcon className="mr-2 h-5 w-5 text-red-500" />
<span>Delete</span>
<TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
<span className="dark:text-red-400">Delete</span>
</ContextMenu.Item>
</ContextMenu.Content>
);
@@ -569,14 +574,14 @@ export function CustomNode({
>
{/* Header */}
<div
className={`flex h-24 border-b border-gray-300 ${data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white"} items-center rounded-t-xl`}
className={`flex h-24 border-b border-gray-300 dark:border-gray-600 ${data.uiType === BlockUIType.NOTE ? "bg-yellow-100 dark:bg-yellow-900" : "bg-white dark:bg-gray-800"} items-center rounded-t-xl`}
>
{/* Color Stripe */}
<div className={`-ml-px h-full w-3 rounded-tl-xl ${stripeColor}`}></div>
<div className="flex w-full flex-col">
<div className="flex flex-row items-center justify-between">
<div className="font-roboto flex items-center px-3 text-lg font-semibold">
<div className="font-roboto flex items-center px-3 text-lg font-semibold dark:text-gray-100">
<TextRenderer
value={beautifyString(
data.blockType?.replace(/Block$/, "") || data.title,
@@ -584,7 +589,7 @@ export function CustomNode({
truncateLengthLimit={80}
/>
<div className="px-2 text-xs text-gray-500">
<div className="px-2 text-xs text-gray-500 dark:text-gray-400">
#{id.split("-")[0]}
</div>
</div>
@@ -603,17 +608,17 @@ export function CustomNode({
<Badge
key={category.category}
variant="outline"
className={`mr-5 ${getPrimaryCategoryColor([category])} whitespace-nowrap rounded-xl border border-gray-300 opacity-50`}
className={`mr-5 ${getPrimaryCategoryColor([category])} whitespace-nowrap rounded-xl border border-gray-300 opacity-50 dark:border-gray-600`}
>
{beautifyString(category.category.toLowerCase())}
</Badge>
))}
<button
aria-label="Options"
className="mr-2 cursor-pointer rounded-full border-none bg-transparent p-1 hover:bg-gray-100"
className="mr-2 cursor-pointer rounded-full border-none bg-transparent p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={onContextButtonTrigger}
>
<DotsVerticalIcon className="h-5 w-5" />
<DotsVerticalIcon className="h-5 w-5 dark:text-gray-100" />
</button>
<ContextMenuContent />
@@ -642,7 +647,7 @@ export function CustomNode({
{data.uiType !== BlockUIType.NOTE && hasAdvancedFields && (
<>
<LineSeparator />
<div className="flex items-center justify-between pt-6">
<div className="flex items-center justify-between pt-6 text-gray-900 dark:text-gray-100">
Advanced
<Switch
onCheckedChange={toggleAdvancedSettings}
@@ -678,7 +683,7 @@ export function CustomNode({
)}
>
{(data.executionResults?.length ?? 0) > 0 ? (
<div className="mt-0 rounded-b-xl bg-gray-50">
<div className="mt-0 rounded-b-xl bg-gray-50 dark:bg-gray-900">
<LineSeparator />
<NodeOutputs
title="Latest Output"
@@ -689,14 +694,14 @@ export function CustomNode({
<Button
variant="ghost"
onClick={handleOutputClick}
className="border border-gray-300"
className="border border-gray-300 dark:border-gray-600"
>
View More
</Button>
</div>
</div>
) : (
<div className="mt-0 min-h-4 rounded-b-xl bg-white"></div>
<div className="mt-0 min-h-4 rounded-b-xl bg-white dark:bg-gray-800"></div>
)}
<div
className={cn(

View File

@@ -661,9 +661,10 @@ const FlowEditor: React.FC<{
deleteKeyCode={["Backspace", "Delete"]}
minZoom={0.2}
maxZoom={2}
className="dark:bg-slate-900"
>
<Controls />
<Background />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-10"
controls={editorControls}

View File

@@ -33,7 +33,7 @@ const NodeHandle: FC<HandleProps> = ({
const label = (
<div className="flex flex-grow flex-row">
<span className="text-m green flex items-end pr-2 text-gray-900">
<span className="text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100">
{schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""}
</span>
@@ -46,10 +46,10 @@ const NodeHandle: FC<HandleProps> = ({
const Dot = ({ className = "" }) => {
const color = isConnected
? getTypeBgColor(schema.type || "any")
: "border-gray-300";
: "border-gray-300 dark:border-gray-600";
return (
<div
className={`${className} ${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300`}
className={`${className} ${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700`}
/>
);
};

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
import { useSupabase } from "./SupabaseProvider";
import { useSupabase } from "./providers/SupabaseProvider";
import { useRouter } from "next/navigation";
import useUser from "@/hooks/useUser";

View File

@@ -14,15 +14,18 @@ const SchemaTooltip: React.FC<{ description?: string }> = ({ description }) => {
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="rounded-full p-1 hover:bg-gray-300" size={24} />
<Info
className="rounded-full p-1 hover:bg-gray-300 dark:hover:bg-gray-700"
size={24}
/>
</TooltipTrigger>
<TooltipContent className="tooltip-content max-w-xs">
<TooltipContent className="tooltip-content max-w-xs bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100">
<ReactMarkdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
className="text-blue-400 underline"
className="text-blue-400 underline dark:text-blue-300"
{...props}
/>
),

View File

@@ -1,12 +1,20 @@
"use client";
import React, { useEffect, useState } from "react";
import { Button } from "./ui/button";
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
const TallyPopupSimple = () => {
const [isFormVisible, setIsFormVisible] = useState(false);
const router = useRouter();
const pathname = usePathname();
const [show_tutorial, setShowTutorial] = useState(false);
useEffect(() => {
setShowTutorial(pathname.includes("build"));
}, [pathname]);
useEffect(() => {
// Load Tally script
@@ -49,21 +57,23 @@ const TallyPopupSimple = () => {
return (
<div className="fixed bottom-1 right-6 z-50 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
{show_tutorial && (
<Button
variant="default"
onClick={resetTutorial}
className="font-inter mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left text-lg font-medium leading-6"
>
Tutorial
</Button>
)}
<Button
variant="default"
onClick={resetTutorial}
className="font-inter mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left text-lg font-medium leading-6"
>
Tutorial
</Button>
<Button
className="h-14 w-14 rounded-2xl bg-[rgba(65,65,64,1)]"
className="h-14 w-14 rounded-full bg-[rgba(65,65,64,1)]"
variant="default"
data-tally-open="3yx2L0"
data-tally-emoji-text="👋"
data-tally-emoji-animation="wave"
>
<QuestionMarkCircledIcon className="h-6 w-6" />
<QuestionMarkCircledIcon className="h-14 w-14" />
<span className="sr-only">Reach Out</span>
</Button>
</div>

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import Image from "next/image";
import { PlayIcon } from "@radix-ui/react-icons";
import { Button } from "./Button";
const isValidVideoFile = (url: string): boolean => {
const videoExtensions = /\.(mp4|webm|ogg)$/i;
return videoExtensions.test(url);
};
const isValidVideoUrl = (url: string): boolean => {
const videoExtensions = /\.(mp4|webm|ogg)$/i;
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
return videoExtensions.test(url) || youtubeRegex.test(url);
};
const getYouTubeVideoId = (url: string) => {
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return match && match[7].length === 11 ? match[7] : null;
};
interface AgentImageItemProps {
image: string;
index: number;
playingVideoIndex: number | null;
handlePlay: (index: number) => void;
handlePause: (index: number) => void;
}
export const AgentImageItem: React.FC<AgentImageItemProps> = React.memo(
({ image, index, playingVideoIndex, handlePlay, handlePause }) => {
const videoRef = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
if (
playingVideoIndex !== index &&
videoRef.current &&
!videoRef.current.paused
) {
videoRef.current.pause();
}
}, [playingVideoIndex, index]);
const isVideoFile = isValidVideoFile(image);
return (
<div className="relative">
<div className="h-[15rem] overflow-hidden rounded-xl bg-[#a8a8a8] dark:bg-neutral-700 sm:h-[20rem] sm:w-full md:h-[25rem] lg:h-[30rem]">
{isValidVideoUrl(image) ? (
getYouTubeVideoId(image) ? (
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${getYouTubeVideoId(image)}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="YouTube video player"
></iframe>
) : (
<div className="relative h-full w-full overflow-hidden">
<video
ref={videoRef}
className="absolute inset-0 h-full w-full object-cover"
controls
preload="metadata"
poster={`${image}#t=0.1`}
style={{ objectPosition: "center 25%" }}
onPlay={() => handlePlay(index)}
onPause={() => handlePause(index)}
autoPlay={false}
title="Video"
>
<source src={image} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
)
) : (
<div className="relative h-full w-full">
<Image
src={image}
alt="Image"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="rounded-xl object-cover"
/>
</div>
)}
</div>
{isVideoFile && playingVideoIndex !== index && (
<div className="absolute bottom-2 left-2 sm:bottom-3 sm:left-3 md:bottom-4 md:left-4 lg:bottom-[1.25rem] lg:left-[1.25rem]">
<Button
variant="default"
size="default"
onClick={() => {
if (videoRef.current) {
videoRef.current.play();
}
}}
>
<span className="pr-1 font-neue text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
Play demo
</span>
<PlayIcon className="h-5 w-5 text-black dark:text-neutral-200 sm:h-6 sm:w-6 md:h-7 md:w-7" />
</Button>
</div>
)}
</div>
);
},
);
AgentImageItem.displayName = "AgentImageItem";

View File

@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentImages } from "./AgentImages";
const meta = {
title: "AGPT UI/Agent Images",
component: AgentImages,
parameters: {
layout: {
center: true,
fullscreen: true,
padding: 0,
},
},
tags: ["autodocs"],
argTypes: {
images: { control: "object" },
},
} satisfies Meta<typeof AgentImages>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
images: [
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
"https://youtu.be/KWonAsyKF3g?si=JMibxlN_6OVo6LhJ",
"https://storage.googleapis.com/agpt-dev-website-media/DJINeo.mp4",
],
},
};
export const OnlyImages: Story = {
args: {
images: [
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
"https://upload.wikimedia.org/wikipedia/commons/c/c5/Big_buck_bunny_poster_big.jpg",
],
},
};
export const WithVideos: Story = {
args: {
images: [
"https://storage.googleapis.com/agpt-dev-website-media/DJINeo.mp4",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
"https://youtu.be/KWonAsyKF3g?si=JMibxlN_6OVo6LhJ",
],
},
};
export const SingleItem: Story = {
args: {
images: [
"https://upload.wikimedia.org/wikipedia/commons/c/c5/Big_buck_bunny_poster_big.jpg",
],
},
};

View File

@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import { AgentImageItem } from "./AgentImageItem";
interface AgentImagesProps {
images: string[];
}
export const AgentImages: React.FC<AgentImagesProps> = ({ images }) => {
const [playingVideoIndex, setPlayingVideoIndex] = React.useState<
number | null
>(null);
const handlePlay = React.useCallback((index: number) => {
setPlayingVideoIndex(index);
}, []);
const handlePause = React.useCallback(
(index: number) => {
if (playingVideoIndex === index) {
setPlayingVideoIndex(null);
}
},
[playingVideoIndex],
);
return (
<div className="w-full overflow-y-auto bg-white px-2 dark:bg-gray-800 lg:w-[56.25rem]">
<div className="space-y-4 sm:space-y-6 md:space-y-[1.875rem]">
{images.map((image, index) => (
<AgentImageItem
key={index}
image={image}
index={index}
playingVideoIndex={playingVideoIndex}
handlePlay={handlePlay}
handlePause={handlePause}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentInfo } from "./AgentInfo";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Agent Info",
component: AgentInfo,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
onRunAgent: { action: "run agent clicked" },
name: { control: "text" },
creator: { control: "text" },
shortDescription: { control: "text" },
longDescription: { control: "text" },
rating: { control: "number", min: 0, max: 5, step: 0.1 },
runs: { control: "number" },
categories: { control: "object" },
lastUpdated: { control: "text" },
version: { control: "text" },
},
} satisfies Meta<typeof AgentInfo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
onRunAgent: () => console.log("Run agent clicked"),
name: "AI Video Generator",
creator: "Toran Richards",
shortDescription:
"Transform ideas into breathtaking images with this AI-powered Image Generator.",
longDescription: `Create Viral-Ready Content in Seconds! Transform trending topics into engaging videos with this cutting-edge AI Video Generator. Perfect for content creators, social media managers, and marketers looking to quickly produce high-quality content.
Key features include:
- Customizable video output
- 15+ pre-made templates
- Auto scene detection
- Smart text-to-speech
- Multiple export formats
- SEO-optimized suggestions`,
rating: 4.7,
runs: 1500,
categories: ["Video", "Content Creation", "Social Media"],
lastUpdated: "2 days ago",
version: "1.2.0",
},
};
export const LowRating: Story = {
args: {
...Default.args,
name: "Data Analyzer",
creator: "DataTech",
shortDescription:
"Analyze complex datasets with machine learning algorithms",
longDescription:
"A comprehensive data analysis tool that leverages machine learning to provide deep insights into your datasets. Currently in beta testing phase.",
rating: 2.7,
runs: 5000,
categories: ["Data Analysis", "Machine Learning"],
lastUpdated: "1 week ago",
version: "0.9.5",
},
};
export const HighRuns: Story = {
args: {
...Default.args,
name: "Code Assistant",
creator: "DevAI",
shortDescription:
"Get AI-powered coding help for various programming languages",
longDescription:
"An advanced AI coding assistant that supports multiple programming languages and frameworks. Features include code completion, refactoring suggestions, and bug detection.",
rating: 4.8,
runs: 1000000,
categories: ["Programming", "AI", "Developer Tools"],
lastUpdated: "1 day ago",
version: "2.1.3",
},
};
export const WithInteraction: Story = {
args: {
...Default.args,
name: "Task Planner",
creator: "Productivity AI",
shortDescription: "Plan and organize your tasks efficiently with AI",
longDescription:
"An intelligent task management system that helps you organize, prioritize, and complete your tasks more efficiently. Features smart scheduling and AI-powered suggestions.",
rating: 4.2,
runs: 50000,
categories: ["Productivity", "Task Management", "AI"],
lastUpdated: "3 days ago",
version: "1.5.2",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Test run agent button
const runButton = canvas.getByText("Run agent");
await userEvent.hover(runButton);
await userEvent.click(runButton);
// Test rating interaction
const ratingStars = canvas.getAllByLabelText(/Star Icon/);
await userEvent.hover(ratingStars[3]);
await userEvent.click(ratingStars[3]);
// Test category interaction
const category = canvas.getByText("Productivity");
await userEvent.hover(category);
await userEvent.click(category);
},
};
export const LongDescription: Story = {
args: {
...Default.args,
name: "AI Writing Assistant",
creator: "WordCraft AI",
shortDescription:
"Enhance your writing with our advanced AI-powered assistant.",
longDescription:
"It offers real-time suggestions for grammar, style, and tone, helps with research and fact-checking, and can even generate content ideas based on your input.",
rating: 4.7,
runs: 75000,
categories: ["Writing", "AI", "Content Creation"],
lastUpdated: "5 days ago",
version: "3.0.1",
},
};

View File

@@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import { IconPlay, IconStar, StarRatingIcons } from "@/components/ui/icons";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
interface AgentInfoProps {
name: string;
creator: string;
shortDescription: string;
longDescription: string;
rating: number;
runs: number;
categories: string[];
lastUpdated: string;
version: string;
}
export const AgentInfo: React.FC<AgentInfoProps> = ({
name,
creator,
shortDescription,
longDescription,
rating,
runs,
categories,
lastUpdated,
version,
}) => {
return (
<div className="w-full max-w-[396px] px-4 sm:px-6 lg:w-[396px] lg:px-0">
{/* Title */}
<div className="mb-3 w-full font-['Poppins'] text-2xl font-medium leading-normal text-neutral-900 dark:text-neutral-100 sm:text-3xl lg:mb-4 lg:text-[35px] lg:leading-10">
{name}
</div>
{/* Creator */}
<div className="mb-3 flex w-full items-center gap-1.5 lg:mb-4">
<div className="font-['Geist'] text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
by
</div>
<div className="font-['Geist'] text-base font-medium text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
{creator}
</div>
</div>
{/* Short Description */}
<div className="mb-4 line-clamp-2 w-full font-['Geist'] text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-6 lg:text-xl lg:leading-7">
{shortDescription}
</div>
{/* Run Agent Button */}
<div className="mb-4 w-full lg:mb-6">
<button className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4">
<IconPlay className="h-5 w-5 text-white sm:h-5 sm:w-5 lg:h-6 lg:w-6" />
<span className="font-['Poppins'] text-base font-medium text-neutral-50 sm:text-lg">
Run agent
</span>
</button>
</div>
{/* Rating and Runs */}
<div className="mb-4 flex w-full items-center justify-between lg:mb-6">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="whitespace-nowrap font-['Geist'] text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{rating.toFixed(1)}
</span>
<div className="flex gap-0.5">{StarRatingIcons(rating)}</div>
</div>
<div className="whitespace-nowrap font-['Geist'] text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{runs.toLocaleString()} runs
</div>
</div>
{/* Separator */}
<Separator className="mb-4 lg:mb-6" />
{/* Description Section */}
<div className="mb-4 w-full lg:mb-6">
<div className="mb-1.5 text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:mb-2 sm:text-sm">
Description
</div>
<div className="w-full whitespace-pre-line font-['Geist'] text-sm font-normal text-neutral-600 dark:text-neutral-300 sm:text-base">
{longDescription}
</div>
</div>
{/* Categories */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-6">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
Categories
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories.map((category, index) => (
<div
key={index}
className="whitespace-nowrap rounded-full border border-neutral-200 bg-white px-2 py-0.5 text-xs text-neutral-800 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-3 sm:py-1 sm:text-sm"
>
{category}
</div>
))}
</div>
</div>
{/* Rate Agent */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-6">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
Rate agent
</div>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<IconStar
key={star}
className="h-4 w-4 cursor-pointer text-neutral-300 hover:text-neutral-800 dark:text-neutral-500 dark:hover:text-neutral-200 sm:h-5 sm:w-5"
/>
))}
</div>
</div>
{/* Version History */}
<div className="flex w-full flex-col gap-0.5 sm:gap-1">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
Version history
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Last updated {lastUpdated}
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Version {version}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentTable } from "./AgentTable";
import { AgentTableRowProps } from "./AgentTableRow";
import { userEvent, within, expect } from "@storybook/test";
import { StatusType } from "./Status";
const meta: Meta<typeof AgentTable> = {
title: "AGPT UI/Agent Table",
component: AgentTable,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof AgentTable>;
const sampleAgents: AgentTableRowProps[] = [
{
id: "agent-1",
agentName: "Super Coder",
description: "An AI agent that writes clean, efficient code",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/53/b2/53b2bc7d7900f0e1e60bf64ebf38032d.jpg",
dateSubmitted: "2023-05-15",
status: "approved",
runs: 1500,
rating: 4.8,
onEdit: () => console.log("Edit Super Coder"),
},
{
id: "agent-2",
agentName: "Data Analyzer",
description: "Processes and analyzes large datasets with ease",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/40/f7/40f7bc97c952f8df0f9c88d29defe8d4.jpg",
dateSubmitted: "2023-05-10",
status: "awaiting_review",
runs: 1200,
rating: 4.5,
onEdit: () => console.log("Edit Data Analyzer"),
},
{
id: "agent-3",
agentName: "UI Designer",
description: "Creates beautiful and intuitive user interfaces",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/14/9e/149ebb9014aa8c0097e72ed89845af0e.jpg",
dateSubmitted: "2023-05-05",
status: "draft",
runs: 800,
rating: 4.2,
onEdit: () => console.log("Edit UI Designer"),
},
];
export const Default: Story = {
args: {
agents: sampleAgents,
},
};
export const EmptyTable: Story = {
args: {
agents: [],
},
};
// Tests
export const InteractionTest: Story = {
...Default,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const editButtons = await canvas.findAllByText("Edit");
await userEvent.click(editButtons[0]);
// You would typically assert something here, but console.log is used in the mocked function
},
};
export const EmptyTableTest: Story = {
...EmptyTable,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emptyMessage = canvas.getByText("No agents found");
expect(emptyMessage).toBeTruthy();
},
};

View File

@@ -0,0 +1,107 @@
"use client";
import * as React from "react";
import { AgentTableRow, AgentTableRowProps } from "./AgentTableRow";
import { AgentTableCard } from "./AgentTableCard";
import { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types";
export interface AgentTableProps {
agents: AgentTableRowProps[];
onEditSubmission: (submission: StoreSubmissionRequest) => void;
onDeleteSubmission: (submission_id: string) => void;
}
export const AgentTable: React.FC<AgentTableProps> = ({
agents,
onEditSubmission,
onDeleteSubmission,
}) => {
// Use state to track selected agents
const [selectedAgents, setSelectedAgents] = React.useState<Set<string>>(
new Set(),
);
// Handle select all checkbox
const handleSelectAll = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedAgents(new Set(agents.map((agent) => agent.id.toString())));
} else {
setSelectedAgents(new Set());
}
},
[agents],
);
return (
<div className="w-full">
{/* Table header - Hide on mobile */}
<div className="hidden flex-col md:flex">
<div className="border-t border-neutral-300 dark:border-neutral-700" />
<div className="flex items-center px-4 py-2">
<div className="flex items-center">
<div className="flex min-w-[120px] items-center">
<input
type="checkbox"
id="selectAllAgents"
aria-label="Select all agents"
className="mr-4 h-5 w-5 rounded border-2 border-neutral-400 dark:border-neutral-600"
checked={
selectedAgents.size === agents.length && agents.length > 0
}
onChange={handleSelectAll}
/>
<label
htmlFor="selectAllAgents"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
Select all
</label>
</div>
</div>
<div className="ml-2 grid w-full grid-cols-[400px,150px,150px,100px,100px,50px] items-center">
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Agent info
</div>
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Date submitted
</div>
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Status
</div>
<div className="text-right text-sm font-medium text-neutral-800 dark:text-neutral-200">
Runs
</div>
<div className="text-right text-sm font-medium text-neutral-800 dark:text-neutral-200">
Reviews
</div>
<div></div>
</div>
</div>
<div className="border-b border-neutral-300 dark:border-neutral-700" />
</div>
{/* Table body */}
{agents.length > 0 ? (
<div className="flex flex-col">
{agents.map((agent, index) => (
<div key={agent.id} className="md:block">
<AgentTableRow
{...agent}
onEditSubmission={onEditSubmission}
onDeleteSubmission={onDeleteSubmission}
/>
<div className="block md:hidden">
<AgentTableCard {...agent} />
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-center font-['Geist'] text-base text-neutral-600 dark:text-neutral-400">
No agents available. Create your first agent to get started!
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentTableCard } from "./AgentTableCard";
import { userEvent, within, expect } from "@storybook/test";
import { type StatusType } from "./Status";
const meta: Meta<typeof AgentTableCard> = {
title: "AGPT UI/Agent Table Card",
component: AgentTableCard,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof AgentTableCard>;
export const Default: Story = {
args: {
agentName: "Super Coder",
description: "An AI agent that writes clean, efficient code",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/53/b2/53b2bc7d7900f0e1e60bf64ebf38032d.jpg",
dateSubmitted: "2023-05-15",
status: "ACTIVE" as StatusType,
runs: 1500,
rating: 4.8,
onEdit: () => console.log("Edit Super Coder"),
},
};
export const NoRating: Story = {
args: {
...Default.args,
rating: undefined,
},
};
export const NoRuns: Story = {
args: {
...Default.args,
runs: undefined,
},
};
export const InactiveAgent: Story = {
args: {
...Default.args,
status: "INACTIVE" as StatusType,
},
};
export const LongDescription: Story = {
args: {
...Default.args,
description:
"This is a very long description that should wrap to multiple lines. It contains detailed information about the agent and its capabilities.",
},
};
export const InteractionTest: Story = {
...Default,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const moreButton = canvas.getByRole("button");
await userEvent.click(moreButton);
},
};

View File

@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { IconStarFilled, IconMore } from "@/components/ui/icons";
import { Status, StatusType } from "./Status";
export interface AgentTableCardProps {
agent_id: string;
agent_version: number;
agentName: string;
sub_heading: string;
description: string;
imageSrc: string[];
dateSubmitted: string;
status: StatusType;
runs: number;
rating: number;
id: number;
onEditSubmission: (submission: StoreSubmissionRequest) => void;
}
export const AgentTableCard: React.FC<AgentTableCardProps> = ({
agent_id,
agent_version,
agentName,
sub_heading,
description,
imageSrc,
dateSubmitted,
status,
runs,
rating,
id,
onEditSubmission,
}) => {
const onEdit = () => {
console.log("Edit agent", agentName);
onEditSubmission({
agent_id,
agent_version,
slug: "",
name: agentName,
sub_heading,
description,
image_urls: imageSrc,
categories: [],
});
};
return (
<div className="border-b border-neutral-300 p-4 dark:border-neutral-700">
<div className="flex gap-4">
<div className="relative h-[56px] w-[100px] overflow-hidden rounded-lg bg-[#d9d9d9] dark:bg-neutral-800">
<Image
src={imageSrc?.[0] ?? "/nada.png"}
alt={agentName}
fill
style={{ objectFit: "cover" }}
/>
</div>
<div className="flex-1">
<h3 className="text-[15px] font-medium text-neutral-800 dark:text-neutral-200">
{agentName}
</h3>
<p className="line-clamp-2 text-sm text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
<button
onClick={onEdit}
className="h-fit rounded-full p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
<IconMore className="h-5 w-5 text-neutral-800 dark:text-neutral-200" />
</button>
</div>
<div className="mt-4 flex flex-wrap gap-4">
<Status status={status} />
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{dateSubmitted}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{runs.toLocaleString()} runs
</div>
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
{rating.toFixed(1)}
</span>
<IconStarFilled className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,170 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { IconStarFilled, IconMore, IconEdit } from "@/components/ui/icons";
import { Status, StatusType } from "./Status";
import * as ContextMenu from "@radix-ui/react-context-menu";
import { TrashIcon } from "@radix-ui/react-icons";
import { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types";
export interface AgentTableRowProps {
agent_id: string;
agent_version: number;
agentName: string;
sub_heading: string;
description: string;
imageSrc: string[];
date_submitted: string;
status: StatusType;
runs: number;
rating: number;
dateSubmitted: string;
id: number;
onEditSubmission: (submission: StoreSubmissionRequest) => void;
onDeleteSubmission: (submission_id: string) => void;
}
export const AgentTableRow: React.FC<AgentTableRowProps> = ({
agent_id,
agent_version,
agentName,
sub_heading,
description,
imageSrc,
dateSubmitted,
status,
runs,
rating,
id,
onEditSubmission,
onDeleteSubmission,
}) => {
// Create a unique ID for the checkbox
const checkboxId = `agent-${id}-checkbox`;
const handleEdit = React.useCallback(() => {
onEditSubmission({
agent_id,
agent_version,
slug: "",
name: agentName,
sub_heading,
description,
image_urls: imageSrc,
categories: [],
} as StoreSubmissionRequest);
}, [
agent_id,
agent_version,
agentName,
sub_heading,
description,
imageSrc,
onEditSubmission,
]);
const handleDelete = React.useCallback(() => {
onDeleteSubmission(agent_id);
}, [agent_id, onDeleteSubmission]);
return (
<div className="hidden items-center border-b border-neutral-300 px-4 py-4 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 md:flex">
<div className="flex items-center">
<div className="flex items-center">
<input
type="checkbox"
id={checkboxId}
aria-label={`Select ${agentName}`}
className="mr-4 h-5 w-5 rounded border-2 border-neutral-400 dark:border-neutral-600"
/>
{/* Single label instead of multiple */}
<label htmlFor={checkboxId} className="sr-only">
Select {agentName}
</label>
</div>
</div>
<div className="grid w-full grid-cols-[minmax(400px,1fr),180px,140px,100px,100px,40px] items-center gap-4">
{/* Agent info column */}
<div className="flex items-center gap-4">
<div className="relative h-[70px] w-[125px] overflow-hidden rounded-[10px] bg-[#d9d9d9] dark:bg-neutral-700">
<Image
src={imageSrc?.[0] ?? "/nada.png"}
alt={agentName}
fill
style={{ objectFit: "cover" }}
/>
</div>
<div className="flex flex-col">
<h3 className="text-[15px] font-medium text-neutral-800 dark:text-neutral-200">
{agentName}
</h3>
<p className="line-clamp-2 text-sm text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
</div>
{/* Date column */}
<div className="pl-14 text-sm text-neutral-600 dark:text-neutral-400">
{dateSubmitted}
</div>
{/* Status column */}
<div>
<Status status={status} />
</div>
{/* Runs column */}
<div className="text-right text-sm text-neutral-600 dark:text-neutral-400">
{runs?.toLocaleString() ?? "0"}
</div>
{/* Reviews column */}
<div className="text-right">
{rating ? (
<div className="flex items-center justify-end gap-1">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
{rating.toFixed(1)}
</span>
<IconStarFilled className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
</div>
) : (
<span className="text-sm text-neutral-600 dark:text-neutral-400">
No reviews
</span>
)}
</div>
{/* Actions - Three dots menu */}
<div className="flex justify-end">
<ContextMenu.Root>
<ContextMenu.Trigger>
<button className="rounded-full p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700">
<IconMore className="h-5 w-5 text-neutral-800 dark:text-neutral-200" />
</button>
</ContextMenu.Trigger>
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item
onSelect={handleEdit}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<IconEdit className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Edit</span>
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
onSelect={handleDelete}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
<span className="dark:text-red-400">Delete</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BecomeACreator } from "./BecomeACreator";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Become A Creator",
component: BecomeACreator,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
title: { control: "text" },
heading: { control: "text" },
description: { control: "text" },
buttonText: { control: "text" },
onButtonClick: { action: "buttonClicked" },
},
} satisfies Meta<typeof BecomeACreator>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Want to contribute?",
heading: "We're always looking for more Creators!",
description: "Join our ever-growing community of hackers and tinkerers",
buttonText: "Become a Creator",
onButtonClick: () => console.log("Button clicked"),
},
};
export const CustomText: Story = {
args: {
title: "Become a Creator Today!",
heading: "Join Our Creator Community",
description: "Share your ideas and build amazing AI agents with us",
buttonText: "Start Creating",
onButtonClick: () => console.log("Custom button clicked"),
},
};
export const LongDescription: Story = {
args: {
...Default.args,
description:
"Join our vibrant community of innovators, developers, and AI enthusiasts. Share your unique perspectives, collaborate on groundbreaking projects, and help shape the future of AI technology.",
},
};
export const WithInteraction: Story = {
args: {
...Default.args,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByText("Become a Creator");
await userEvent.click(button);
},
};

View File

@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { PublishAgentPopout } from "./composite/PublishAgentPopout";
interface BecomeACreatorProps {
title?: string;
description?: string;
buttonText?: string;
onButtonClick?: () => void;
}
export const BecomeACreator: React.FC<BecomeACreatorProps> = ({
title = "Become a creator",
description = "Join a community where your AI creations can inspire, engage, and be downloaded by users around the world.",
buttonText = "Upload your agent",
onButtonClick,
}) => {
const handleButtonClick = () => {
onButtonClick?.();
console.log("Become A Creator clicked");
};
return (
<div className="relative mx-auto h-auto min-h-[300px] w-full max-w-[1360px] px-4 md:min-h-[400px] md:px-6 lg:h-[459px] lg:px-8">
{/* Top border */}
<div className="left-0 top-0 h-px w-full bg-gray-200 dark:bg-gray-700" />
{/* Title */}
<div className="left-4 top-[26px] mb-4 pt-4 font-['Poppins'] text-base font-semibold leading-7 text-neutral-800 dark:text-neutral-200 md:left-6 md:text-lg lg:left-8">
{title}
</div>
{/* Content Container */}
<div className="absolute left-1/2 top-1/2 w-full max-w-[900px] -translate-x-1/2 -translate-y-1/2 px-4 pt-16 text-center md:px-6 md:pt-10 lg:px-0">
<h2 className="font-poppins mb-6 text-3xl font-semibold leading-tight text-neutral-950 dark:text-neutral-50 md:mb-8 md:text-4xl md:leading-[1.2] lg:mb-12 lg:text-5xl lg:leading-[54px]">
Build AI agents and share
<br />
<span className="text-violet-600 dark:text-violet-400">
your
</span>{" "}
vision
</h2>
<p className="font-geist mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
{description}
</p>
<PublishAgentPopout
trigger={
<button
onClick={handleButtonClick}
className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5"
>
<span className="font-poppins whitespace-nowrap text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7">
{buttonText}
</span>
</button>
}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BreadCrumbs } from "./BreadCrumbs";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/BreadCrumbs",
component: BreadCrumbs,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
items: { control: "object" },
},
} satisfies Meta<typeof BreadCrumbs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Agents", link: "/agents" },
{ name: "SEO Optimizer", link: "/agents/seo-optimizer" },
],
},
};
export const SingleItem: Story = {
args: {
items: [{ name: "Home", link: "/" }],
},
};
export const LongPath: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Categories", link: "/categories" },
{ name: "AI Tools", link: "/categories/ai-tools" },
{ name: "Data Analysis", link: "/categories/ai-tools/data-analysis" },
{
name: "Data Analyzer",
link: "/categories/ai-tools/data-analysis/data-analyzer",
},
],
},
};
export const WithInteraction: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Agents", link: "/agents" },
{ name: "Task Planner", link: "/agents/task-planner" },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const homeLink = canvas.getByText("Home");
await userEvent.hover(homeLink);
await userEvent.click(homeLink);
},
};
export const LongNames: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "AI-Powered Writing Assistants", link: "/ai-writing-assistants" },
{
name: "Advanced Grammar and Style Checker",
link: "/ai-writing-assistants/grammar-style-checker",
},
],
},
};

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import Link from "next/link";
import { IconLeftArrow, IconRightArrow } from "@/components/ui/icons";
interface BreadcrumbItem {
name: string;
link: string;
}
interface BreadCrumbsProps {
items: BreadcrumbItem[];
}
export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
return (
<div className="flex items-center gap-4">
{/*
Commented out for now, but keeping until we have approval to remove
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconLeftArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button>
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconRightArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button> */}
<div className="flex h-auto min-h-[4.375rem] flex-wrap items-center justify-start gap-4 rounded-[5rem] bg-white dark:bg-neutral-900">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link href={item.link}>
<span className="rounded py-1 pr-2 font-neue text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
{item.name}
</span>
</Link>
{index < items.length - 1 && (
<span className="font-['SF Pro'] text-center text-2xl font-normal text-black dark:text-neutral-100">
/
</span>
)}
</React.Fragment>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,221 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
import { userEvent, within, expect } from "@storybook/test";
const meta = {
title: "AGPT UI/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: [
"default",
"destructive",
"outline",
"secondary",
"ghost",
"link",
],
},
size: {
control: "select",
options: ["default", "sm", "lg", "primary", "icon"],
},
disabled: {
control: "boolean",
},
asChild: {
control: "boolean",
},
children: {
control: "text",
},
onClick: { action: "clicked" },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: "Button",
},
};
export const Interactive: Story = {
args: {
children: "Interactive Button",
},
argTypes: {
onClick: { action: "clicked" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Interactive Button/i });
await userEvent.click(button);
await expect(button).toHaveFocus();
},
};
export const Variants: Story = {
render: (args) => (
<div className="flex flex-wrap gap-2">
<Button {...args} variant="default">
Default
</Button>
<Button {...args} variant="destructive">
Destructive
</Button>
<Button {...args} variant="outline">
Outline
</Button>
<Button {...args} variant="secondary">
Secondary
</Button>
<Button {...args} variant="ghost">
Ghost
</Button>
<Button {...args} variant="link">
Link
</Button>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = canvas.getAllByRole("button");
expect(buttons).toHaveLength(6);
for (const button of buttons) {
await userEvent.hover(button);
await expect(button).toHaveAttribute(
"class",
expect.stringContaining("hover:"),
);
}
},
};
export const Sizes: Story = {
render: (args) => (
<div className="flex flex-wrap items-center gap-2">
<Button {...args} size="sm">
Small
</Button>
<Button {...args} size="default">
Default
</Button>
<Button {...args} size="lg">
Large
</Button>
<Button {...args} size="primary">
Primary
</Button>
<Button {...args} size="icon">
🚀
</Button>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = canvas.getAllByRole("button");
expect(buttons).toHaveLength(5);
const sizeClasses = [
"h-8 px-3 py-1.5 text-xs",
"h-10 px-4 py-2 text-sm",
"h-12 px-5 py-2.5 text-lg",
"h-10 w-28",
"h-10 w-10",
];
buttons.forEach((button, index) => {
expect(button).toHaveAttribute(
"class",
expect.stringContaining(sizeClasses[index]),
);
});
},
};
export const Disabled: Story = {
args: {
children: "Disabled Button",
disabled: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Disabled Button/i });
await expect(button).toBeDisabled();
await expect(button).toHaveAttribute(
"class",
expect.stringContaining("disabled:opacity-50"),
);
await expect(button).not.toHaveFocus();
},
};
export const WithIcon: Story = {
args: {
children: (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
Button with Icon
</>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Button with Icon/i });
const icon = button.querySelector("svg");
await expect(icon).toBeInTheDocument();
await expect(button).toHaveTextContent("Button with Icon");
},
};
export const LoadingState: Story = {
args: {
children: "Loading...",
disabled: true,
},
render: (args) => (
<Button {...args}>
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{args.children}
</Button>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Loading.../i });
expect(button).toBeDisabled();
const spinner = button.querySelector("svg");
await expect(spinner).toHaveClass("animate-spin");
},
};

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-[80px] text-xl font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300 font-neue leading-9 tracking-tight",
{
variants: {
variant: {
default:
"bg-white border border-black/50 text-[#272727] hover:bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700",
destructive:
"bg-red-600 text-neutral-50 border border-red-500/50 hover:bg-red-500/90 dark:bg-red-700 dark:text-neutral-50 dark:hover:bg-red-600",
outline:
"bg-white border border-black/50 text-[#272727] hover:bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700",
secondary:
"bg-neutral-100 text-[#272727] border border-neutral-200 hover:bg-neutral-100/80 dark:bg-neutral-700 dark:text-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-600",
ghost:
"hover:bg-neutral-100 text-[#272727] dark:text-neutral-100 dark:hover:bg-neutral-700",
link: "text-[#272727] underline-offset-4 hover:underline dark:text-neutral-100",
},
size: {
default:
"h-10 px-4 py-2 text-sm sm:h-12 sm:px-5 sm:py-2.5 sm:text-base md:h-14 md:px-6 md:py-3 md:text-lg lg:h-[4.375rem] lg:px-[1.625rem] lg:py-[0.4375rem] lg:text-xl",
sm: "h-8 px-3 py-1.5 text-xs sm:h-9 sm:px-3.5 sm:py-2 sm:text-sm md:h-10 md:px-4 md:py-2 md:text-base lg:h-[3.125rem] lg:px-[1.25rem] lg:py-[0.3125rem] lg:text-sm",
lg: "h-12 px-5 py-2.5 text-lg sm:h-14 sm:px-6 sm:py-3 sm:text-xl md:h-16 md:px-7 md:py-3.5 md:text-2xl lg:h-[5.625rem] lg:px-[2rem] lg:py-[0.5625rem] lg:text-2xl",
primary:
"h-10 w-28 sm:h-12 sm:w-32 md:h-[4.375rem] md:w-[11rem] lg:h-[3.125rem] lg:w-[7rem]",
icon: "h-10 w-10 sm:h-12 sm:w-12 md:h-14 md:w-14 lg:h-[4.375rem] lg:w-[4.375rem]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "primary" | "icon";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreatorCard } from "./CreatorCard";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Creator Card",
component: CreatorCard,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
creatorName: { control: "text" },
creatorImage: { control: "text" },
bio: { control: "text" },
agentsUploaded: { control: "number" },
onClick: { action: "clicked" },
avatarSrc: { control: "text" },
},
} satisfies Meta<typeof CreatorCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
creatorName: "John Doe",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "AI enthusiast and developer with a passion for creating innovative agents.",
agentsUploaded: 15,
onClick: () => console.log("Default CreatorCard clicked"),
avatarSrc: "https://github.com/shadcn.png",
},
};
export const NewCreator: Story = {
args: {
creatorName: "Jane Smith",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "Excited to start my journey in AI agent development!",
agentsUploaded: 1,
onClick: () => console.log("NewCreator CreatorCard clicked"),
avatarSrc: "https://example.com/avatar2.jpg",
},
};
export const ExperiencedCreator: Story = {
args: {
creatorName: "Alex Johnson",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "Veteran AI researcher with a focus on natural language processing and machine learning.",
agentsUploaded: 50,
onClick: () => console.log("ExperiencedCreator CreatorCard clicked"),
avatarSrc: "https://example.com/avatar3.jpg",
},
};
export const WithInteraction: Story = {
args: {
creatorName: "Sam Brown",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "Exploring the frontiers of AI and its applications in everyday life.",
agentsUploaded: 30,
onClick: () => console.log("WithInteraction CreatorCard clicked"),
avatarSrc: "https://example.com/avatar4.jpg",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const creatorCard = canvas.getByText("Sam Brown");
await userEvent.hover(creatorCard);
await userEvent.click(creatorCard);
},
};

View File

@@ -0,0 +1,63 @@
import * as React from "react";
import Image from "next/image";
const BACKGROUND_COLORS = [
"bg-amber-100 dark:bg-amber-800", // #fef3c7 / #92400e
"bg-violet-100 dark:bg-violet-800", // #ede9fe / #5b21b6
"bg-green-100 dark:bg-green-800", // #dcfce7 / #065f46
"bg-blue-100 dark:bg-blue-800", // #dbeafe / #1e3a8a
];
interface CreatorCardProps {
creatorName: string;
creatorImage: string;
bio: string;
agentsUploaded: number;
onClick: () => void;
index: number;
}
export const CreatorCard: React.FC<CreatorCardProps> = ({
creatorName,
creatorImage,
bio,
agentsUploaded,
onClick,
index,
}) => {
const backgroundColor = BACKGROUND_COLORS[index % BACKGROUND_COLORS.length];
return (
<div
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-all duration-200 hover:brightness-95`}
onClick={onClick}
data-testid="creator-card"
>
<div className="relative h-[64px] w-[64px]">
<div className="absolute inset-0 overflow-hidden rounded-full">
<Image
src={creatorImage}
alt={creatorName}
width={64}
height={64}
className="h-full w-full object-cover"
priority
/>
</div>
</div>
<div className="flex flex-1 flex-col items-start justify-start self-stretch">
<div className="mb-1 self-stretch font-['Poppins'] text-2xl font-semibold leading-loose text-neutral-800 dark:text-neutral-200">
{creatorName}
</div>
<div className="line-clamp-2 self-stretch font-['Geist'] text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
{bio}
</div>
</div>
<div className="self-stretch font-['Geist'] text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{agentsUploaded} agents
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreatorInfoCard } from "./CreatorInfoCard";
const meta = {
title: "AGPT UI/Creator Info Card",
component: CreatorInfoCard,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
username: { control: "text" },
handle: { control: "text" },
avatarSrc: { control: "text" },
categories: { control: "object" },
averageRating: { control: "number", min: 0, max: 5, step: 0.1 },
totalRuns: { control: "number" },
},
} satisfies Meta<typeof CreatorInfoCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
username: "SignificantGravitas",
handle: "oliviagrace1421",
avatarSrc: "https://github.com/shadcn.png",
categories: ["Entertainment", "Business"],
averageRating: 4.7,
totalRuns: 1500,
},
};
export const NewCreator: Story = {
args: {
username: "AI Enthusiast",
handle: "ai_newbie",
avatarSrc: "https://example.com/avatar2.jpg",
categories: ["AI", "Technology"],
averageRating: 0,
totalRuns: 0,
},
};
export const ExperiencedCreator: Story = {
args: {
username: "Tech Master",
handle: "techmaster",
avatarSrc: "https://example.com/avatar3.jpg",
categories: ["AI", "Development", "Education"],
averageRating: 4.9,
totalRuns: 50000,
},
};

View File

@@ -0,0 +1,105 @@
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { StarRatingIcons } from "@/components/ui/icons";
interface CreatorInfoCardProps {
username: string;
handle: string;
avatarSrc: string;
categories: string[];
averageRating: number;
totalRuns: number;
}
export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
username,
handle,
avatarSrc,
categories,
averageRating,
totalRuns,
}) => {
return (
<div
className="inline-flex h-auto min-h-[500px] w-full max-w-[440px] flex-col items-start justify-between rounded-[26px] bg-violet-100 p-4 dark:bg-violet-900 sm:h-[632px] sm:w-[440px] sm:p-6"
role="article"
aria-label={`Creator profile for ${username}`}
>
<div className="flex w-full flex-col items-start justify-start gap-3.5 sm:h-[218px]">
<Avatar className="h-[100px] w-[100px] sm:h-[130px] sm:w-[130px]">
<AvatarImage src={avatarSrc} alt={`${username}'s avatar`} />
<AvatarFallback className="h-[100px] w-[100px] sm:h-[130px] sm:w-[130px]">
{username.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex w-full flex-col items-start justify-start gap-1.5">
<div className="font-poppins w-full text-2xl font-medium leading-8 text-neutral-900 dark:text-neutral-100 sm:text-[35px] sm:leading-10">
{username}
</div>
<div className="w-full font-neue text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
@{handle}
</div>
</div>
</div>
<div className="my-4 flex w-full flex-col items-start justify-start gap-6 sm:gap-[50px]">
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
<div className="flex flex-col items-start justify-start gap-2.5">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Top categories
</div>
<div
className="flex flex-wrap items-center gap-2.5"
role="list"
aria-label="Categories"
>
{categories.map((category, index) => (
<div
key={index}
className="flex items-center justify-center gap-2.5 rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
role="listitem"
>
<div className="font-neue text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
{category}
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:gap-0">
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Average rating
</div>
<div className="inline-flex items-center gap-2">
<div className="font-neue text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{averageRating.toFixed(1)}
</div>
<div
className="flex items-center gap-px"
role="img"
aria-label={`Rating: ${averageRating} out of 5 stars`}
>
{StarRatingIcons(averageRating)}
</div>
</div>
</div>
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Number of runs
</div>
<div className="font-neue text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{new Intl.NumberFormat().format(totalRuns)} runs
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreatorLinks } from "./CreatorLinks";
const meta = {
title: "AGPT UI/Creator Links",
component: CreatorLinks,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
links: {
control: "object",
description: "Object containing various social and web links",
},
},
} satisfies Meta<typeof CreatorLinks>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
links: {
website: "https://example.com",
linkedin: "https://linkedin.com/in/johndoe",
github: "https://github.com/johndoe",
other: ["https://twitter.com/johndoe", "https://medium.com/@johndoe"],
},
},
};
export const WebsiteOnly: Story = {
args: {
links: {
website: "https://example.com",
},
},
};
export const SocialLinks: Story = {
args: {
links: {
linkedin: "https://linkedin.com/in/janedoe",
github: "https://github.com/janedoe",
other: ["https://twitter.com/janedoe"],
},
},
};
export const NoLinks: Story = {
args: {
links: {},
},
};
export const MultipleOtherLinks: Story = {
args: {
links: {
website: "https://example.com",
linkedin: "https://linkedin.com/in/creator",
github: "https://github.com/creator",
other: [
"https://twitter.com/creator",
"https://medium.com/@creator",
"https://youtube.com/@creator",
"https://tiktok.com/@creator",
],
},
},
};

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { getIconForSocial } from "@/components/ui/icons";
interface CreatorLinksProps {
links: string[];
}
export const CreatorLinks: React.FC<CreatorLinksProps> = ({ links }) => {
if (!links || links.length === 0) {
return null;
}
const renderLinkButton = (url: string) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-[200px] flex-1 items-center justify-between rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
>
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{new URL(url).hostname.replace("www.", "")}
</div>
<div className="relative h-6 w-6">
{getIconForSocial(url, {
className: "h-6 w-6 text-neutral-800 dark:text-neutral-200",
})}
</div>
</a>
);
return (
<div className="flex flex-col items-start justify-start gap-4">
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Other links
</div>
<div className="flex w-full flex-wrap gap-3">
{links.map((link, index) => (
<React.Fragment key={index}>{renderLinkButton(link)}</React.Fragment>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react";
import CreditsCard from "./CreditsCard";
import { userEvent, within } from "@storybook/test";
const meta: Meta<typeof CreditsCard> = {
title: "AGPT UI/Credits Card",
component: CreditsCard,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CreditsCard>;
export const Default: Story = {
args: {
credits: 0,
},
};
export const SmallNumber: Story = {
args: {
credits: 10,
},
};
export const LargeNumber: Story = {
args: {
credits: 1000000,
},
};
export const InteractionTest: Story = {
args: {
credits: 100,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const refreshButton = canvas.getByRole("button", {
name: /refresh credits/i,
});
await userEvent.click(refreshButton);
},
};

View File

@@ -0,0 +1,53 @@
"use client";
import { IconRefresh } from "@/components/ui/icons";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface CreditsCardProps {
credits: number;
}
const CreditsCard = ({ credits }: CreditsCardProps) => {
const [currentCredits, setCurrentCredits] = useState(credits);
const api = new AutoGPTServerAPI();
const onRefresh = async () => {
const { credits } = await api.getUserCredit("credits-card");
setCurrentCredits(credits);
};
return (
<div className="inline-flex h-[60px] items-center gap-2.5 rounded-2xl bg-neutral-200 p-4 dark:bg-neutral-800">
<div className="flex items-center gap-0.5">
<span className="font-['Poppins'] text-base font-semibold leading-7 text-neutral-900 dark:text-neutral-50">
{currentCredits.toLocaleString()}
</span>
<span className="pl-1 font-['Poppins'] text-base font-semibold leading-7 text-neutral-900 dark:text-neutral-50">
credits
</span>
</div>
<Tooltip key="RefreshCredits" delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={onRefresh}
className="h-6 w-6 transition-colors hover:text-neutral-700 dark:hover:text-neutral-300"
aria-label="Refresh credits"
>
<IconRefresh className="h-6 w-6" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh credits</p>
</TooltipContent>
</Tooltip>
</div>
);
};
export default CreditsCard;

View File

@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FeaturedStoreCard } from "./FeaturedStoreCard";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Featured Store Card",
component: FeaturedStoreCard,
parameters: {
layout: {
center: true,
padding: 0,
},
},
tags: ["autodocs"],
argTypes: {
agentName: { control: "text" },
subHeading: { control: "text" },
agentImage: { control: "text" },
creatorImage: { control: "text" },
creatorName: { control: "text" },
description: { control: "text" },
runs: { control: "number" },
rating: { control: "number", min: 0, max: 5, step: 0.1 },
onClick: { action: "clicked" },
},
} satisfies Meta<typeof FeaturedStoreCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
agentName: "Personalized Morning Coffee Newsletter example of three lines",
subHeading:
"Transform ideas into breathtaking images with this AI-powered Image Generator.",
description:
"Elevate your web content with this powerful AI Webpage Copy Improver. Designed for marketers, SEO specialists, and web developers, this tool analyses and enhances website copy for maximum impact. Using advanced language models, it optimizes text for better clarity, SEO performance, and increased conversion rates.",
agentImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorName: "AI Solutions Inc.",
runs: 50000,
rating: 4.7,
onClick: () => console.log("Card clicked"),
},
};
export const LowRating: Story = {
args: {
agentName: "Data Analyzer Lite",
subHeading: "Basic data analysis tool",
description:
"A lightweight data analysis tool for basic data processing needs.",
agentImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorName: "DataTech",
runs: 10000,
rating: 2.8,
onClick: () => console.log("Card clicked"),
},
};
export const HighRuns: Story = {
args: {
agentName: "CodeAssist AI",
subHeading: "Your AI coding companion",
description:
"An intelligent coding assistant that helps developers write better code faster.",
agentImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorName: "DevTools Co.",
runs: 1000000,
rating: 4.9,
onClick: () => console.log("Card clicked"),
},
};
export const NoCreatorImage: Story = {
args: {
agentName: "MultiTasker",
subHeading: "All-in-one productivity suite",
description:
"A comprehensive productivity suite that combines task management, note-taking, and project planning into one seamless interface.",
agentImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorName: "Productivity Plus",
runs: 75000,
rating: 4.5,
onClick: () => console.log("Card clicked"),
},
};
export const ShortDescription: Story = {
args: {
agentName: "QuickTask",
subHeading: "Fast task automation",
description: "Simple and efficient task automation tool.",
agentImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorName: "EfficientWorks",
runs: 50000,
rating: 4.2,
onClick: () => console.log("Card clicked"),
},
};
export const WithInteraction: Story = {
args: {
agentName: "AI Writing Assistant",
subHeading: "Enhance your writing",
description:
"An AI-powered writing assistant that helps improve your writing style and clarity.",
agentImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
creatorName: "WordCraft AI",
runs: 200000,
rating: 4.6,
onClick: () => console.log("Card clicked"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const featuredCard = canvas.getByTestId("featured-store-card");
await userEvent.hover(featuredCard);
await userEvent.click(featuredCard);
},
};

View File

@@ -0,0 +1,96 @@
import * as React from "react";
import Image from "next/image";
import { StarRatingIcons } from "@/components/ui/icons";
interface FeaturedStoreCardProps {
agentName: string;
subHeading: string;
agentImage: string;
creatorImage?: string;
creatorName: string;
description: string; // Added description prop
runs: number;
rating: number;
onClick: () => void;
backgroundColor: string;
}
export const FeaturedStoreCard: React.FC<FeaturedStoreCardProps> = ({
agentName,
subHeading,
agentImage,
creatorImage,
creatorName,
description,
runs,
rating,
onClick,
backgroundColor,
}) => {
return (
<div
className={`group h-[755px] w-[440px] px-[22px] pb-5 pt-[30px] ${backgroundColor} inline-flex flex-col items-start justify-start gap-7 rounded-[26px] transition-all duration-200 hover:brightness-95 dark:bg-neutral-800`}
onClick={onClick}
data-testid="featured-store-card"
>
<div className="flex h-[188px] flex-col items-start justify-start gap-3 self-stretch">
<div className="self-stretch font-['Poppins'] text-[35px] font-medium leading-10 text-neutral-900 dark:text-neutral-100">
{agentName}
</div>
<div className="self-stretch font-['Geist'] text-xl font-normal leading-7 text-neutral-800 dark:text-neutral-200">
{subHeading}
</div>
</div>
<div className="flex h-[489px] flex-col items-start justify-start gap-[18px] self-stretch">
<div className="self-stretch font-['Geist'] text-xl font-normal leading-7 text-neutral-800 dark:text-neutral-200">
by {creatorName}
</div>
<div className="relative h-[397px] self-stretch">
<Image
src={agentImage}
alt={`${agentName} preview`}
layout="fill"
objectFit="cover"
className="rounded-xl transition-opacity duration-200 group-hover:opacity-0"
/>
<div className="absolute inset-0 overflow-y-auto rounded-xl bg-white p-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:bg-neutral-700">
<div className="font-['Geist'] text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
{description}
</div>
</div>
{creatorImage && (
<div className="absolute left-[8.74px] top-[313px] h-[74px] w-[74px] overflow-hidden rounded-full transition-opacity duration-200 group-hover:opacity-0">
<Image
src={creatorImage}
alt={`${creatorName} image`}
layout="fill"
className="object-cover"
priority
/>
</div>
)}
</div>
<div className="inline-flex items-center justify-between self-stretch">
<div className="font-['Inter'] text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{runs.toLocaleString()} runs
</div>
<div className="flex items-center justify-start gap-[5px]">
<div className="font-['Inter'] text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{rating.toFixed(1)}
</div>
<div
className="inline-flex items-center justify-start gap-px"
role="img"
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
>
{StarRatingIcons(rating)}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,123 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FilterChips } from "./FilterChips";
import { userEvent, within, expect } from "@storybook/test";
const meta = {
title: "AGPT UI/Filter Chips",
component: FilterChips,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
badges: { control: "object" },
onFilterChange: { action: "onFilterChange" },
multiSelect: { control: "boolean" },
},
} satisfies Meta<typeof FilterChips>;
export default meta;
type Story = StoryObj<typeof meta>;
const defaultBadges = [
"Marketing",
"Sales",
"Content creation",
"Lorem ipsum",
"Lorem ipsum",
];
export const Default: Story = {
args: {
badges: defaultBadges,
multiSelect: true,
},
};
export const SingleSelect: Story = {
args: {
badges: defaultBadges,
multiSelect: false,
},
};
export const WithSelectedFilters: Story = {
args: {
badges: defaultBadges,
multiSelect: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const marketingChip = canvas.getByText("Marketing").parentElement;
const salesChip = canvas.getByText("Sales").parentElement;
if (!marketingChip || !salesChip) {
throw new Error("Marketing or Sales chip not found");
}
await userEvent.click(marketingChip);
await userEvent.click(salesChip);
expect(marketingChip).toHaveClass("bg-neutral-100");
expect(salesChip).toHaveClass("bg-neutral-100");
},
};
export const WithFilterChangeCallback: Story = {
args: {
badges: defaultBadges,
multiSelect: true,
onFilterChange: (selectedFilters: string[]) => {
console.log("Selected filters:", selectedFilters);
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const salesChip = canvas.getByText("Sales");
const marketingChip = canvas.getByText("Marketing");
await userEvent.click(salesChip);
await userEvent.click(marketingChip);
},
};
export const EmptyBadges: Story = {
args: {
badges: [],
multiSelect: true,
},
};
export const LongBadgeNames: Story = {
args: {
badges: [
"Machine Learning",
"Natural Language Processing",
"Computer Vision",
"Data Science",
],
multiSelect: true,
},
};
export const SingleSelectBehavior: Story = {
args: {
badges: defaultBadges,
multiSelect: false,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const salesChip = canvas.getByText("Sales").parentElement;
const marketingChip = canvas.getByText("Marketing").parentElement;
if (!salesChip || !marketingChip) {
throw new Error("Sales or Marketing chip not found");
}
await userEvent.click(salesChip);
expect(salesChip).toHaveClass("bg-neutral-100");
await userEvent.click(marketingChip);
expect(marketingChip).toHaveClass("bg-neutral-100");
expect(salesChip).not.toHaveClass("bg-neutral-100");
},
};

View File

@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
interface FilterChipsProps {
badges: string[];
onFilterChange?: (selectedFilters: string[]) => void;
multiSelect?: boolean;
}
/** FilterChips is a component that allows the user to select filters from a list of badges. It is used on the Agent Store home page */
export const FilterChips: React.FC<FilterChipsProps> = ({
badges,
onFilterChange,
multiSelect = true,
}) => {
const [selectedFilters, setSelectedFilters] = React.useState<string[]>([]);
const handleBadgeClick = (badge: string) => {
setSelectedFilters((prevFilters) => {
let newFilters;
if (multiSelect) {
newFilters = prevFilters.includes(badge)
? prevFilters.filter((filter) => filter !== badge)
: [...prevFilters, badge];
} else {
newFilters = prevFilters.includes(badge) ? [] : [badge];
}
if (onFilterChange) {
onFilterChange(newFilters);
}
return newFilters;
});
};
return (
<div className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5">
{badges.map((badge) => (
<Badge
key={badge}
variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
onClick={() => handleBadgeClick(badge)}
>
<div className="font-neue text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
{badge}
</div>
</Badge>
))}
</div>
);
};

View File

@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MobileNavBar } from "./MobileNavBar";
import { userEvent, within } from "@storybook/test";
import { IconType } from "../ui/icons";
const meta = {
title: "AGPT UI/Mobile Nav Bar",
component: MobileNavBar,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
userName: { control: "text" },
userEmail: { control: "text" },
activeLink: { control: "text" },
avatarSrc: { control: "text" },
menuItemGroups: { control: "object" },
},
} satisfies Meta<typeof MobileNavBar>;
export default meta;
type Story = StoryObj<typeof meta>;
const defaultMenuItemGroups = [
{
items: [
{ icon: IconType.Marketplace, text: "Marketplace", href: "/marketplace" },
{ icon: IconType.Library, text: "Library", href: "/library" },
{ icon: IconType.Builder, text: "Builder", href: "/builder" },
],
},
{
items: [
{ icon: IconType.Edit, text: "Edit profile", href: "/profile/edit" },
],
},
{
items: [
{
icon: IconType.LayoutDashboard,
text: "Creator Dashboard",
href: "/dashboard",
},
{
icon: IconType.UploadCloud,
text: "Publish an agent",
href: "/publish",
},
],
},
{
items: [{ icon: IconType.Settings, text: "Settings", href: "/settings" }],
},
{
items: [
{
icon: IconType.LogOut,
text: "Log out",
onClick: () => console.log("Logged out"),
},
],
},
];
export const Default: Story = {
args: {
userName: "John Doe",
userEmail: "john.doe@example.com",
activeLink: "/marketplace",
avatarSrc: "https://avatars.githubusercontent.com/u/123456789?v=4",
menuItemGroups: defaultMenuItemGroups,
},
};
export const NoAvatar: Story = {
args: {
userName: "Jane Smith",
userEmail: "jane.smith@example.com",
activeLink: "/library",
menuItemGroups: defaultMenuItemGroups,
},
};
export const LongUserName: Story = {
args: {
userName: "Alexander Bartholomew Christopherson III",
userEmail: "alexander@example.com",
activeLink: "/builder",
avatarSrc: "https://avatars.githubusercontent.com/u/987654321?v=4",
menuItemGroups: defaultMenuItemGroups,
},
};
export const WithInteraction: Story = {
args: {
...Default.args,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const menuTrigger = canvas.getByRole("button");
await userEvent.click(menuTrigger);
// Wait for the popover to appear
await canvas.findByText("Edit profile");
},
};

View File

@@ -0,0 +1,196 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverPortal,
} from "@/components/ui/popover";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import {
IconType,
IconMenu,
IconChevronUp,
IconEdit,
IconLayoutDashboard,
IconUploadCloud,
IconSettings,
IconLogOut,
IconMarketplace,
IconLibrary,
IconBuilder,
} from "../ui/icons";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { usePathname } from "next/navigation";
interface MobileNavBarProps {
userName?: string;
userEmail?: string;
avatarSrc?: string;
menuItemGroups: {
groupName?: string;
items: {
icon: IconType;
text: string;
href?: string;
onClick?: () => void;
}[];
}[];
}
const Overlay = React.forwardRef<HTMLDivElement, { children: React.ReactNode }>(
({ children }, ref) => (
<div ref={ref} className="h-screen w-screen backdrop-blur-md">
{children}
</div>
),
);
Overlay.displayName = "Overlay";
const PopoutMenuItem: React.FC<{
icon: IconType;
isActive: boolean;
text: React.ReactNode;
href?: string;
onClick?: () => void;
}> = ({ icon, isActive, text, href, onClick }) => {
const getIcon = (iconType: IconType) => {
const iconClass = "w-6 h-6 relative";
switch (iconType) {
case IconType.Marketplace:
return <IconMarketplace className={iconClass} />;
case IconType.Library:
return <IconLibrary className={iconClass} />;
case IconType.Builder:
return <IconBuilder className={iconClass} />;
case IconType.Edit:
return <IconEdit className={iconClass} />;
case IconType.LayoutDashboard:
return <IconLayoutDashboard className={iconClass} />;
case IconType.UploadCloud:
return <IconUploadCloud className={iconClass} />;
case IconType.Settings:
return <IconSettings className={iconClass} />;
case IconType.LogOut:
return <IconLogOut className={iconClass} />;
default:
return null;
}
};
const content = (
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0] dark:hover:bg-[#3a3a3a]">
{getIcon(icon)}
<div className="relative">
<div
className={`font-['Inter'] text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf] ${isActive ? "font-semibold text-[#272727] dark:text-[#ffffff]" : "text-[#474747] dark:text-[#cfcfcf]"}`}
>
{text}
</div>
{isActive && (
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727] dark:bg-[#ffffff]"></div>
)}
</div>
</div>
);
if (onClick)
return (
<div className="w-full" onClick={onClick}>
{content}
</div>
);
if (href)
return (
<Link href={href} className="w-full">
{content}
</Link>
);
return content;
};
export const MobileNavBar: React.FC<MobileNavBarProps> = ({
userName,
userEmail,
avatarSrc,
menuItemGroups,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const pathname = usePathname();
const parts = pathname.split("/");
const activeLink = parts.length > 1 ? parts[1] : parts[0];
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
aria-label="Open menu"
className="fixed right-4 top-4 z-50 flex h-14 w-14 items-center justify-center rounded-lg border border-neutral-500 bg-neutral-200 hover:bg-gray-200/50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-gray-700/50 md:hidden"
>
{isOpen ? (
<IconChevronUp className="h-8 w-8 stroke-black dark:stroke-white" />
) : (
<IconMenu className="h-8 w-8 stroke-black dark:stroke-white" />
)}
<span className="sr-only">Open menu</span>
</Button>
</PopoverTrigger>
<AnimatePresence>
<PopoverPortal>
<Overlay>
<PopoverContent asChild>
<motion.div
initial={{ opacity: 0, y: -32 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -32, transition: { duration: 0.2 } }}
className="w-screen rounded-b-2xl bg-white dark:bg-neutral-900"
>
<div className="mb-4 inline-flex items-end justify-start gap-4">
<Avatar className="h-14 w-14 border border-[#474747] dark:border-[#cfcfcf]">
<AvatarImage
src={avatarSrc}
alt={userName || "Unknown User"}
/>
<AvatarFallback>
{userName?.charAt(0) || "U"}
</AvatarFallback>
</Avatar>
<div className="relative h-14 w-[153px]">
<div className="absolute left-0 top-0 font-['Inter'] text-lg font-semibold leading-7 text-[#474747] dark:text-[#cfcfcf]">
{userName || "Unknown User"}
</div>
<div className="absolute left-0 top-6 font-['Inter'] text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
{userEmail || "No Email Set"}
</div>
</div>
</div>
<Separator className="mb-4 dark:bg-[#3a3a3a]" />
{menuItemGroups.map((group, groupIndex) => (
<React.Fragment key={groupIndex}>
{group.items.map((item, itemIndex) => (
<PopoutMenuItem
key={itemIndex}
icon={item.icon}
isActive={item.href === activeLink}
text={item.text}
onClick={item.onClick}
href={item.href}
/>
))}
{groupIndex < menuItemGroups.length - 1 && (
<Separator className="my-4 dark:bg-[#3a3a3a]" />
)}
</React.Fragment>
))}
</motion.div>
</PopoverContent>
</Overlay>
</PopoverPortal>
</AnimatePresence>
</Popover>
);
};

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from "@storybook/react";
import Navbar from "./Navbar";
import { userEvent, within } from "@storybook/test";
import { IconType } from "../ui/icons";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { jest } from "@jest/globals";
// Mock the API responses
const mockProfileData: ProfileDetails = {
name: "John Doe",
username: "johndoe",
description: "",
links: [],
avatar_url: "https://avatars.githubusercontent.com/u/123456789?v=4",
};
const mockCreditData = {
credits: 1500,
};
// Mock the API module
jest.mock("@/lib/autogpt-server-api", () => {
return function () {
return {
getStoreProfile: () => Promise.resolve(mockProfileData),
getUserCredit: () => Promise.resolve(mockCreditData),
};
};
});
const meta = {
title: "AGPT UI/Navbar",
component: Navbar,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
argTypes: {
isLoggedIn: { control: "boolean" },
avatarSrc: { control: "text" },
links: { control: "object" },
activeLink: { control: "text" },
menuItemGroups: { control: "object" },
params: { control: { type: "object", defaultValue: { lang: "en" } } },
},
} satisfies Meta<typeof Navbar>;
export default meta;
type Story = StoryObj<typeof meta>;
const defaultMenuItemGroups = [
{
items: [
{ icon: IconType.Edit, text: "Edit profile", href: "/profile/edit" },
],
},
{
items: [
{
icon: IconType.LayoutDashboard,
text: "Creator Dashboard",
href: "/dashboard",
},
{
icon: IconType.UploadCloud,
text: "Publish an agent",
href: "/publish",
},
],
},
{
items: [{ icon: IconType.Settings, text: "Settings", href: "/settings" }],
},
{
items: [
{
icon: IconType.LogOut,
text: "Log out",
onClick: () => console.log("Logged out"),
},
],
},
];
const defaultLinks = [
{ name: "Marketplace", href: "/marketplace" },
{ name: "Library", href: "/library" },
{ name: "Build", href: "/builder" },
];
export const Default: Story = {
args: {
params: { lang: "en" },
isLoggedIn: true,
links: defaultLinks,
activeLink: "/marketplace",
avatarSrc: mockProfileData.avatar_url,
menuItemGroups: defaultMenuItemGroups,
},
};
export const WithActiveLink: Story = {
args: {
...Default.args,
activeLink: "/library",
},
};
export const LongUserName: Story = {
args: {
...Default.args,
avatarSrc: "https://avatars.githubusercontent.com/u/987654321?v=4",
},
};
export const NoAvatar: Story = {
args: {
...Default.args,
avatarSrc: undefined,
},
};
export const WithInteraction: Story = {
args: {
...Default.args,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const profileTrigger = canvas.getByRole("button");
await userEvent.click(profileTrigger);
// Wait for the popover to appear
await canvas.findByText("Edit profile");
},
};
export const NotLoggedIn: Story = {
args: {
...Default.args,
isLoggedIn: false,
avatarSrc: undefined,
},
};
export const WithCredits: Story = {
args: {
...Default.args,
},
};
export const WithLargeCredits: Story = {
args: {
...Default.args,
},
};
export const WithZeroCredits: Story = {
args: {
...Default.args,
},
};

View File

@@ -0,0 +1,148 @@
import * as React from "react";
import Link from "next/link";
import { ProfilePopoutMenu } from "./ProfilePopoutMenu";
import {
IconType,
IconLogIn,
IconBuilder,
IconMarketplace,
IconLibrary,
} from "@/components/ui/icons";
import { MobileNavBar } from "./MobileNavBar";
import { Button } from "./Button";
import CreditsCard from "./CreditsCard";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { User } from "@supabase/supabase-js";
import AutoGPTServerAPIServerSide from "@/lib/autogpt-server-api/clientServer";
import { ThemeToggle } from "./ThemeToggle";
import { NavbarLink } from "./NavbarLink";
interface NavLink {
name: string;
href: string;
}
interface NavbarProps {
user: User | null;
isLoggedIn: boolean;
links: NavLink[];
menuItemGroups: {
groupName?: string;
items: {
icon: IconType;
text: string;
href?: string;
onClick?: () => void;
}[];
}[];
}
async function getProfileData(user: User | null) {
const api = new AutoGPTServerAPIServerSide();
const [profile, credits] = await Promise.all([
api.getStoreProfile("navbar"),
api.getUserCredit("navbar"),
]);
return {
profile,
credits,
};
}
export const Navbar = async ({
user,
isLoggedIn,
links,
menuItemGroups,
}: NavbarProps) => {
let profile: ProfileDetails | null = null;
let credits: { credits: number } = { credits: 0 };
if (isLoggedIn) {
const { profile: t_profile, credits: t_credits } =
await getProfileData(user);
profile = t_profile;
credits = t_credits;
}
return (
<>
<nav className="sticky top-0 z-50 hidden h-20 w-[1408px] items-center justify-between rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 py-3 pl-6 pr-3 backdrop-blur-[26px] dark:border-gray-700 dark:bg-gray-900 md:inline-flex">
<div className="flex items-center space-x-10">
{links.map((link) => (
<NavbarLink key={link.name} name={link.name} href={link.href} />
))}
</div>
{/* Profile section */}
<div className="flex items-center gap-4">
{isLoggedIn ? (
<div className="flex items-center gap-4">
{profile && <CreditsCard credits={credits.credits} />}
<ProfilePopoutMenu
menuItemGroups={menuItemGroups}
userName={profile?.username}
userEmail={profile?.name}
avatarSrc={profile?.avatar_url}
/>
</div>
) : (
<Link href="/login">
<Button
variant="default"
size="sm"
className="flex items-center justify-end space-x-2"
>
<IconLogIn className="h-5 w-5" />
<span>Log In</span>
</Button>
</Link>
)}
<ThemeToggle />
</div>
</nav>
{/* Mobile Navbar - Adjust positioning */}
<>
{isLoggedIn ? (
<div className="fixed right-4 top-4 z-50">
<MobileNavBar
userName={profile?.username}
menuItemGroups={[
{
groupName: "Navigation",
items: links.map((link) => ({
icon:
link.name === "Agent Store"
? IconType.Marketplace
: link.name === "Library"
? IconType.Library
: link.name === "Build"
? IconType.Builder
: IconType.LayoutDashboard,
text: link.name,
href: link.href,
})),
},
...menuItemGroups,
]}
userEmail={profile?.name}
avatarSrc={profile?.avatar_url}
/>
</div>
) : (
<Link
href="/login"
className="fixed right-4 top-4 z-50 mt-4 inline-flex h-8 items-center justify-end rounded-lg pr-4 md:hidden"
>
<Button
variant="default"
size="sm"
className="flex items-center space-x-2"
>
<IconLogIn className="h-5 w-5" />
<span>Log In</span>
</Button>
</Link>
)}
</>
</>
);
};

Some files were not shown because too many files have changed in this diff Show More