From 5f50c4863d7d09d51a59af298eadf9da67268139 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 15 Jan 2025 03:41:41 -0600 Subject: [PATCH 01/38] test(frontend): Re-enable the tests in monitor.spec.ts and then ensure they pass (#9248) Enable the tests in `monitor.spec.ts`. * Remove `test.describe.skip` to enable the tests. * Ensure the tests are now running and passing successfully. --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/Significant-Gravitas/AutoGPT/pull/9248?shareId=edbd64cc-ea19-477b-be06-5eea84c28665). --- .../src/components/node-input-components.tsx | 2 - .../frontend/src/tests/build.spec.ts | 68 ++++++++++++++----- .../frontend/src/tests/monitor.spec.ts | 22 +++--- .../frontend/src/tests/pages/build.page.ts | 44 +++++++++++- .../frontend/src/tests/pages/monitor.page.ts | 18 +++-- 5 files changed, 117 insertions(+), 37 deletions(-) diff --git a/autogpt_platform/frontend/src/components/node-input-components.tsx b/autogpt_platform/frontend/src/components/node-input-components.tsx index b06c4f0321b0..56c0015760cd 100644 --- a/autogpt_platform/frontend/src/components/node-input-components.tsx +++ b/autogpt_platform/frontend/src/components/node-input-components.tsx @@ -313,8 +313,6 @@ export const NodeGenericInputField: FC<{ ); } - console.log("propSchema", propSchema); - if ("properties" in propSchema) { // Render a multi-select for all-boolean sub-schemas with more than 3 properties if ( diff --git a/autogpt_platform/frontend/src/tests/build.spec.ts b/autogpt_platform/frontend/src/tests/build.spec.ts index 3400b6f282c4..c3b8cf0d5a83 100644 --- a/autogpt_platform/frontend/src/tests/build.spec.ts +++ b/autogpt_platform/frontend/src/tests/build.spec.ts @@ -42,39 +42,75 @@ test.describe("Build", () => { //(1)! }); // --8<-- [end:BuildPageExample] - test("user can add all blocks", async ({ page }, testInfo) => { + test("user can add all blocks a-l", async ({ page }, testInfo) => { // this test is slow af so we 10x the timeout (sorry future me) - await test.setTimeout(testInfo.timeout * 10); + await test.setTimeout(testInfo.timeout * 100); await test.expect(buildPage.isLoaded()).resolves.toBeTruthy(); await test.expect(page).toHaveURL(new RegExp("/.*build")); await buildPage.closeTutorial(); await buildPage.openBlocksPanel(); const blocks = await buildPage.getBlocks(); - // add all the blocks in order + const blocksToSkip = await buildPage.getBlocksToSkip(); + + // add all the blocks in order except for the agent executor block for (const block of blocks) { - if (block.id !== "e189baac-8c20-45a1-94a7-55177ea42565") { + if (block.name[0].toLowerCase() >= "m") { + continue; + } + if (!blocksToSkip.some((b) => b === block.id)) { await buildPage.addBlock(block); } } await buildPage.closeBlocksPanel(); // check that all the blocks are visible for (const block of blocks) { - if (block.id !== "e189baac-8c20-45a1-94a7-55177ea42565") { + if (block.name[0].toLowerCase() >= "m") { + continue; + } + if (!blocksToSkip.some((b) => b === block.id)) { + console.log("Checking block:", block.name); await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy(); } } - // fill in the input for the agent input block - await buildPage.fillBlockInputByPlaceholder( - blocks.find((b) => b.name === "Agent Input")?.id ?? "", - "Enter Name", - "Agent Input Field", - ); - await buildPage.fillBlockInputByPlaceholder( - blocks.find((b) => b.name === "Agent Output")?.id ?? "", - "Enter Name", - "Agent Output Field", - ); + + // check that we can save the agent with all the blocks + await buildPage.saveAgent("all blocks test", "all blocks test"); + // page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340 + await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+")); + }); + + test("user can add all blocks m-z", async ({ page }, testInfo) => { + // this test is slow af so we 10x the timeout (sorry future me) + await test.setTimeout(testInfo.timeout * 100); + await test.expect(buildPage.isLoaded()).resolves.toBeTruthy(); + await test.expect(page).toHaveURL(new RegExp("/.*build")); + await buildPage.closeTutorial(); + await buildPage.openBlocksPanel(); + const blocks = await buildPage.getBlocks(); + + const blocksToSkip = await buildPage.getBlocksToSkip(); + + // add all the blocks in order except for the agent executor block + for (const block of blocks) { + if (block.name[0].toLowerCase() < "m") { + continue; + } + if (!blocksToSkip.some((b) => b === block.id)) { + await buildPage.addBlock(block); + } + } + await buildPage.closeBlocksPanel(); + // check that all the blocks are visible + for (const block of blocks) { + if (block.name[0].toLowerCase() < "m") { + continue; + } + if (!blocksToSkip.some((b) => b === block.id)) { + await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy(); + } + } + // check that we can save the agent with all the blocks await buildPage.saveAgent("all blocks test", "all blocks test"); // page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340 diff --git a/autogpt_platform/frontend/src/tests/monitor.spec.ts b/autogpt_platform/frontend/src/tests/monitor.spec.ts index a8a7221f75e6..3aa66096fcf0 100644 --- a/autogpt_platform/frontend/src/tests/monitor.spec.ts +++ b/autogpt_platform/frontend/src/tests/monitor.spec.ts @@ -6,8 +6,7 @@ import { v4 as uuidv4 } from "uuid"; import * as fs from "fs/promises"; import path from "path"; // --8<-- [start:AttachAgentId] - -test.describe.skip("Monitor", () => { +test.describe("Monitor", () => { let buildPage: BuildPage; let monitorPage: MonitorPage; @@ -54,21 +53,25 @@ test.describe.skip("Monitor", () => { await test.expect(agents.length).toBeGreaterThan(0); }); - test("user can export and import agents", async ({ + test.skip("user can export and import agents", async ({ page, }, testInfo: TestInfo) => { // --8<-- [start:ReadAgentId] if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) { throw new Error("No agent id attached to the test"); } - const id = testInfo.attachments[0].body.toString(); + const testAttachName = testInfo.attachments[0].body.toString(); // --8<-- [end:ReadAgentId] const agents = await monitorPage.listAgents(); const downloadPromise = page.waitForEvent("download"); - await monitorPage.exportToFile( - agents.find((a: any) => a.id === id) || agents[0], + const agent = agents.find( + (a: any) => a.name === `test-agent-${testAttachName}`, ); + if (!agent) { + throw new Error(`Agent ${testAttachName} not found`); + } + await monitorPage.exportToFile(agent); const download = await downloadPromise; // Wait for the download process to complete and save the downloaded file somewhere. @@ -78,9 +81,6 @@ test.describe.skip("Monitor", () => { console.log(`downloaded file to ${download.suggestedFilename()}`); await test.expect(download.suggestedFilename()).toBeDefined(); // test-agent-uuid-v1.json - if (id) { - await test.expect(download.suggestedFilename()).toContain(id); - } await test.expect(download.suggestedFilename()).toContain("test-agent-"); await test.expect(download.suggestedFilename()).toContain("v1.json"); @@ -89,9 +89,9 @@ test.describe.skip("Monitor", () => { const filesInFolder = await fs.readdir( `${monitorPage.downloadsFolder}/monitor`, ); - const importFile = filesInFolder.find((f) => f.includes(id)); + const importFile = filesInFolder.find((f) => f.includes(testAttachName)); if (!importFile) { - throw new Error(`No import file found for agent ${id}`); + throw new Error(`No import file found for agent ${testAttachName}`); } const baseName = importFile.split(".")[0]; await monitorPage.importFromFile( diff --git a/autogpt_platform/frontend/src/tests/pages/build.page.ts b/autogpt_platform/frontend/src/tests/pages/build.page.ts index 63191add1059..0e76a045c7a8 100644 --- a/autogpt_platform/frontend/src/tests/pages/build.page.ts +++ b/autogpt_platform/frontend/src/tests/pages/build.page.ts @@ -1,7 +1,7 @@ import { ElementHandle, Locator, Page } from "@playwright/test"; import { BasePage } from "./base.page"; -interface Block { +export interface Block { id: string; name: string; description: string; @@ -378,6 +378,39 @@ export class BuildPage extends BasePage { }; } + async getAgentExecutorBlockDetails(): Promise { + return { + id: "e189baac-8c20-45a1-94a7-55177ea42565", + name: "Agent Executor", + description: "Executes an existing agent inside your agent", + }; + } + + async getAgentOutputBlockDetails(): Promise { + return { + id: "363ae599-353e-4804-937e-b2ee3cef3da4", + name: "Agent Output", + description: "This block is used to output the result of an agent.", + }; + } + + async getAgentInputBlockDetails(): Promise { + return { + id: "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b", + name: "Agent Input", + description: "This block is used to provide input to the graph.", + }; + } + + async getGithubTriggerBlockDetails(): Promise { + return { + id: "6c60ec01-8128-419e-988f-96a063ee2fea", + name: "Github Trigger", + description: + "This block triggers on pull request events and outputs the event type and payload.", + }; + } + async nextTutorialStep(): Promise { console.log(`clicking next tutorial step`); await this.page.getByRole("button", { name: "Next" }).click(); @@ -448,6 +481,15 @@ export class BuildPage extends BasePage { ); } + async getBlocksToSkip(): Promise { + return [ + (await this.getAgentExecutorBlockDetails()).id, + (await this.getAgentInputBlockDetails()).id, + (await this.getAgentOutputBlockDetails()).id, + (await this.getGithubTriggerBlockDetails()).id, + ]; + } + async waitForRunTutorialButton(): Promise { console.log(`waiting for run tutorial button`); await this.page.waitForSelector('[id="press-run-label"]'); diff --git a/autogpt_platform/frontend/src/tests/pages/monitor.page.ts b/autogpt_platform/frontend/src/tests/pages/monitor.page.ts index 06954dab9878..1679c5397ffb 100644 --- a/autogpt_platform/frontend/src/tests/pages/monitor.page.ts +++ b/autogpt_platform/frontend/src/tests/pages/monitor.page.ts @@ -43,9 +43,6 @@ export class MonitorPage extends BasePage { async isLoaded(): Promise { console.log(`checking if monitor page is loaded`); try { - // Wait for network to settle first - await this.page.waitForLoadState("networkidle", { timeout: 10_000 }); - // Wait for the monitor page await this.page.getByTestId("monitor-page").waitFor({ state: "visible", @@ -55,7 +52,7 @@ export class MonitorPage extends BasePage { // Wait for table headers to be visible (indicates table structure is ready) await this.page.locator("thead th").first().waitFor({ state: "visible", - timeout: 5_000, + timeout: 15_000, }); // Wait for either a table row or an empty tbody to be present @@ -63,14 +60,14 @@ export class MonitorPage extends BasePage { // Wait for at least one row this.page.locator("tbody tr[data-testid]").first().waitFor({ state: "visible", - timeout: 5_000, + timeout: 15_000, }), // OR wait for an empty tbody (indicating no agents but table is loaded) this.page .locator("tbody[data-testid='agent-flow-list-body']:empty") .waitFor({ state: "visible", - timeout: 5_000, + timeout: 15_000, }), ]); @@ -114,6 +111,13 @@ export class MonitorPage extends BasePage { }); } + agents.reduce((acc, agent) => { + if (!agent.id.includes("flow-run")) { + acc.push(agent); + } + return acc; + }, [] as Agent[]); + return agents; } @@ -219,7 +223,7 @@ export class MonitorPage extends BasePage { async exportToFile(agent: Agent) { await this.clickAgent(agent.id); - console.log(`exporting agent ${agent.id} ${agent.name} to file`); + console.log(`exporting agent id: ${agent.id} name: ${agent.name} to file`); await this.page.getByTestId("export-button").click(); } From 9d79bfadea8d9850a320089f91bcb1339ffa8367 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 15 Jan 2025 12:11:13 -0600 Subject: [PATCH 02/38] [Snyk] Security upgrade next from 14.2.20 to 14.2.21 (#9243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ![snyk-top-banner](https://redirect.github.com/andygongea/OWASP-Benchmark/assets/818805/c518c423-16fe-447e-b67f-ad5a49b5d123) ### Snyk has created this PR to fix 1 vulnerabilities in the yarn dependencies of this project. #### Snyk changed the following file(s): - `autogpt_platform/frontend/package.json` - `autogpt_platform/frontend/yarn.lock` #### Note for [zero-installs](https://yarnpkg.com/features/zero-installs) users If you are using the Yarn feature [zero-installs](https://yarnpkg.com/features/zero-installs) that was introduced in Yarn V2, note that this PR does not update the `.yarn/cache/` directory meaning this code cannot be pulled and immediately developed on as one would expect for a zero-install project - you will need to run `yarn` to update the contents of the `./yarn/cache` directory. If you are not using zero-install you can ignore this as your flow should likely be unchanged. #### Vulnerabilities that will be fixed with an upgrade: | | Issue | :-------------------------:|:------------------------- ![medium severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/m.png 'medium severity') | Allocation of Resources Without Limits or Throttling
[SNYK-JS-NEXT-8602067](https://snyk.io/vuln/SNYK-JS-NEXT-8602067) --- > [!IMPORTANT] > > - Check the changes in this PR to ensure they won't cause issues with your project. > - Max score is 1000. Note that the real score may have changed since the PR was raised. > - This PR was automatically created by Snyk using the credentials of a real user. --- **Note:** _You are seeing this because you or someone else with access to this repository has authorized Snyk to open fix PRs._ For more information: 🧐 [View latest project report](https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source=github&utm_medium=referral&page=fix-pr) 📜 [Customise PR templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates?utm_source=github&utm_content=fix-pr-template) 🛠 [Adjust project settings](https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source=github&utm_medium=referral&page=fix-pr/settings) 📚 [Read about Snyk's upgrade logic](https://docs.snyk.io/scan-with-snyk/snyk-open-source/manage-vulnerabilities/upgrade-package-versions-to-fix-vulnerabilities?utm_source=github&utm_content=fix-pr-template) --- **Learn how to fix vulnerabilities with free interactive lessons:** 🦉 [Allocation of Resources Without Limits or Throttling](https://learn.snyk.io/lesson/no-rate-limiting/?loc=fix-pr) [//]: # 'snyk:metadata:{"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"next","from":"14.2.20","to":"14.2.21"}],"env":"prod","issuesToFix":["SNYK-JS-NEXT-8602067"],"prId":"85f7482c-74ab-416f-b488-015009fc6979","prPublicId":"85f7482c-74ab-416f-b488-015009fc6979","packageManager":"yarn","priorityScoreList":[null],"projectPublicId":"3d924968-0cf3-4767-9609-501fa4962856","projectUrl":"https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["updated-fix-title"],"type":"auto","upgrade":["SNYK-JS-NEXT-8602067"],"vulns":["SNYK-JS-NEXT-8602067"],"patch":[],"isBreakingChange":false,"remediationStrategy":"vuln"}' Co-authored-by: snyk-bot --- autogpt_platform/frontend/package.json | 2 +- autogpt_platform/frontend/yarn.lock | 124 ++++++++++++------------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index eb6a43039829..0ec8cca69c15 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -64,7 +64,7 @@ "launchdarkly-react-client-sdk": "^3.6.0", "lucide-react": "^0.469.0", "moment": "^2.30.1", - "next": "^14.2.13", + "next": "^14.2.21", "next-themes": "^0.4.4", "react": "^18", "react-day-picker": "^9.5.0", diff --git a/autogpt_platform/frontend/yarn.lock b/autogpt_platform/frontend/yarn.lock index 325db2ae9933..e382ca15afc2 100644 --- a/autogpt_platform/frontend/yarn.lock +++ b/autogpt_platform/frontend/yarn.lock @@ -1693,10 +1693,10 @@ outvariant "^1.4.3" strict-event-emitter "^0.5.1" -"@next/env@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02" - integrity sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw== +"@next/env@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.23.tgz#3003b53693cbc476710b856f83e623c8231a6be9" + integrity sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA== "@next/eslint-plugin-next@15.1.3": version "15.1.3" @@ -1705,50 +1705,50 @@ dependencies: fast-glob "3.3.1" -"@next/swc-darwin-arm64@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.20.tgz#3c99d318c08362aedde5d2778eec3a50b8085d99" - integrity sha512-WDfq7bmROa5cIlk6ZNonNdVhKmbCv38XteVFYsxea1vDJt3SnYGgxLGMTXQNfs5OkFvAhmfKKrwe7Y0Hs+rWOg== - -"@next/swc-darwin-x64@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.20.tgz#fd547fad1446a677f29c1160006fdd482bba4052" - integrity sha512-XIQlC+NAmJPfa2hruLvr1H1QJJeqOTDV+v7tl/jIdoFvqhoihvSNykLU/G6NMgoeo+e/H7p/VeWSOvMUHKtTIg== - -"@next/swc-linux-arm64-gnu@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.20.tgz#1d6ba1929d3a11b74c0185cdeca1e38b824222ca" - integrity sha512-pnzBrHTPXIMm5QX3QC8XeMkpVuoAYOmyfsO4VlPn+0NrHraNuWjdhe+3xLq01xR++iCvX+uoeZmJDKcOxI201Q== - -"@next/swc-linux-arm64-musl@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.20.tgz#0fe0c67b5d916f99ca76b39416557af609768f17" - integrity sha512-WhJJAFpi6yqmUx1momewSdcm/iRXFQS0HU2qlUGlGE/+98eu7JWLD5AAaP/tkK1mudS/rH2f9E3WCEF2iYDydQ== - -"@next/swc-linux-x64-gnu@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.20.tgz#6d29fa8cdb6a9f8250c2048aaa24538f0cd0b02d" - integrity sha512-ao5HCbw9+iG1Kxm8XsGa3X174Ahn17mSYBQlY6VGsdsYDAbz/ZP13wSLfvlYoIDn1Ger6uYA+yt/3Y9KTIupRg== - -"@next/swc-linux-x64-musl@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.20.tgz#bfc57482bc033fda8455e8aab1c3cbc44f0c4690" - integrity sha512-CXm/kpnltKTT7945np6Td3w7shj/92TMRPyI/VvveFe8+YE+/YOJ5hyAWK5rpx711XO1jBCgXl211TWaxOtkaA== - -"@next/swc-win32-arm64-msvc@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.20.tgz#6f7783e643310510240a981776532ffe0e02af95" - integrity sha512-upJn2HGQgKNDbXVfIgmqT2BN8f3z/mX8ddoyi1I565FHbfowVK5pnMEwauvLvaJf4iijvuKq3kw/b6E9oIVRWA== - -"@next/swc-win32-ia32-msvc@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.20.tgz#58c7720687e80a13795e22c29d5860fa142e44fc" - integrity sha512-igQW/JWciTGJwj3G1ipalD2V20Xfx3ywQy17IV0ciOUBbFhNfyU1DILWsTi32c8KmqgIDviUEulW/yPb2FF90w== - -"@next/swc-win32-x64-msvc@14.2.20": - version "14.2.20" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.20.tgz#689bc7beb8005b73c95d926e7edfb7f73efc78f2" - integrity sha512-AFmqeLW6LtxeFTuoB+MXFeM5fm5052i3MU6xD0WzJDOwku6SkZaxb1bxjBaRC8uNqTRTSPl0yMFtjNowIVI67w== +"@next/swc-darwin-arm64@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz#6d83f03e35e163e8bbeaf5aeaa6bf55eed23d7a1" + integrity sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ== + +"@next/swc-darwin-x64@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz#e02abc35d5e36ce1550f674f8676999f293ba54f" + integrity sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q== + +"@next/swc-linux-arm64-gnu@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz#f13516ad2d665950951b59e7c239574bb8504d63" + integrity sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg== + +"@next/swc-linux-arm64-musl@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz#10d05a1c161dc8426d54ccf6d9bbed6953a3252a" + integrity sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g== + +"@next/swc-linux-x64-gnu@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz#7f5856df080f58ba058268b30429a2ab52500536" + integrity sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ== + +"@next/swc-linux-x64-musl@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz#d494ebdf26421c91be65f9b1d095df0191c956d8" + integrity sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA== + +"@next/swc-win32-arm64-msvc@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz#62786e7ba4822a20b6666e3e03e5a389b0e7eb3b" + integrity sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ== + +"@next/swc-win32-ia32-msvc@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz#ef028af91e1c40a4ebba0d2c47b23c1eeb299594" + integrity sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA== + +"@next/swc-win32-x64-msvc@14.2.23": + version "14.2.23" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz#c81838f02f2f16a321b7533890fb63c1edec68e1" + integrity sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw== "@next/third-parties@^15.1.3": version "15.1.3" @@ -8976,12 +8976,12 @@ next-themes@^0.4.4: resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.4.tgz#ce6f68a4af543821bbc4755b59c0d3ced55c2d13" integrity sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ== -next@^14.2.13: - version "14.2.20" - resolved "https://registry.yarnpkg.com/next/-/next-14.2.20.tgz#99b551d87ca6505ce63074904cb31a35e21dac9b" - integrity sha512-yPvIiWsiyVYqJlSQxwmzMIReXn5HxFNq4+tlVQ812N1FbvhmE+fDpIAD7bcS2mGYQwPJ5vAsQouyme2eKsxaug== +next@^14.2.21: + version "14.2.23" + resolved "https://registry.yarnpkg.com/next/-/next-14.2.23.tgz#37edc9a4d42c135fd97a4092f829e291e2e7c943" + integrity sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw== dependencies: - "@next/env" "14.2.20" + "@next/env" "14.2.23" "@swc/helpers" "0.5.5" busboy "1.6.0" caniuse-lite "^1.0.30001579" @@ -8989,15 +8989,15 @@ next@^14.2.13: postcss "8.4.31" styled-jsx "5.1.1" optionalDependencies: - "@next/swc-darwin-arm64" "14.2.20" - "@next/swc-darwin-x64" "14.2.20" - "@next/swc-linux-arm64-gnu" "14.2.20" - "@next/swc-linux-arm64-musl" "14.2.20" - "@next/swc-linux-x64-gnu" "14.2.20" - "@next/swc-linux-x64-musl" "14.2.20" - "@next/swc-win32-arm64-msvc" "14.2.20" - "@next/swc-win32-ia32-msvc" "14.2.20" - "@next/swc-win32-x64-msvc" "14.2.20" + "@next/swc-darwin-arm64" "14.2.23" + "@next/swc-darwin-x64" "14.2.23" + "@next/swc-linux-arm64-gnu" "14.2.23" + "@next/swc-linux-arm64-musl" "14.2.23" + "@next/swc-linux-x64-gnu" "14.2.23" + "@next/swc-linux-x64-musl" "14.2.23" + "@next/swc-win32-arm64-msvc" "14.2.23" + "@next/swc-win32-ia32-msvc" "14.2.23" + "@next/swc-win32-x64-msvc" "14.2.23" no-case@^3.0.4: version "3.0.4" From 04915f2db0e4c7f8add1e26949cbb830b7e6b1f4 Mon Sep 17 00:00:00 2001 From: Krzysztof Czerwinski <34861343+kcze@users.noreply.github.com> Date: Thu, 16 Jan 2025 00:46:52 +0100 Subject: [PATCH 03/38] feat(platform): Implement top-up flow for PAYG System (#9050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds Stripe integration and payment processing for topping-up user accounts with credits. ### Changes 🏗️ Includes: - https://github.com/Significant-Gravitas/AutoGPT/pull/9176 #### Top-up flow 1. To top-up a user visits their settings and clicks `Credits` button (it's unavailable if `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` isn't present) 2. User inputs top-up amount (min 5$ in 1$ increments) and click the button to confirm. 3. Backend receives top-up request, creates database entry and requests stripe to provide url for this specific checkout. 4. User gets redirected to externally hosted Stripe checkout page, after payment (or cancelling) they get redirected back to Credits page. 5. In the meantime Stripe processes payment and sends webhook confirmation to the backend, backend updates database to activate bought credits. 6. Credits page shows success (or failure) information (by using url param `topup=success|cancel`). Credit counter won't update without refreshing the page unless payment was confirmed before user was back on Credits page which is the case when testing checkout locally. Screenshot 2025-01-01 at 2 55 35 PM #### Backend - Add `stripe` package - Add environment variables: - `STRIPE_API_KEY` - `STRIPE_WEBHOOK_SECRET` - Add routes: - `POST /credits`: top-up request, returns Stripe checkout url. - `POST /credits/stripe_webhook`: Stripe webhook endpoint to notify of successful payment. - `PATCH /credits`: prompts beckend to check payment status. It's an additional failsafe in case webhook fails. - Update `credit.py` and related files to handle top-up request and payment confirmation #### Frontend - Add `stripe-js` package - Add `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` environment variable - Modify user settings sidebar to show `Credits` if `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` is available - Add `store/credits` page where user can top-up their account, it shows confirmation (or failure) after completing checkout. - Add `useCredits` hook that returns user credits and allows to request top-up. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [x] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
--------- Co-authored-by: Zamil Majdy --- autogpt_platform/backend/.env.example | 5 +- .../backend/backend/blocks/exa/contents.py | 2 +- .../backend/backend/data/credit.py | 368 ++++++++++++++---- autogpt_platform/backend/backend/data/db.py | 9 + .../backend/backend/executor/database.py | 6 +- .../backend/backend/executor/manager.py | 5 +- .../backend/backend/server/model.py | 5 + .../backend/backend/server/routers/v1.py | 53 ++- .../backend/backend/util/settings.py | 11 +- .../migration.sql | 2 + autogpt_platform/backend/poetry.lock | 18 +- autogpt_platform/backend/pyproject.toml | 1 + autogpt_platform/backend/schema.prisma | 2 + .../backend/test/data/test_credit.py | 59 ++- autogpt_platform/frontend/.env.example | 1 + autogpt_platform/frontend/package.json | 1 + .../src/app/store/(user)/credits/page.tsx | 73 ++++ .../src/app/store/(user)/dashboard/page.tsx | 6 +- .../frontend/src/app/store/(user)/layout.tsx | 1 + .../frontend/src/app/store/search/page.tsx | 2 +- .../src/components/agptui/Sidebar.tsx | 27 ++ .../src/components/nav/CreditButton.tsx | 26 +- .../frontend/src/components/ui/icons.tsx | 2 +- .../frontend/src/hooks/useAgentGraph.ts | 2 +- .../frontend/src/hooks/useCredits.ts | 48 +++ .../src/lib/autogpt-server-api/client.ts | 8 + autogpt_platform/frontend/yarn.lock | 5 + 27 files changed, 599 insertions(+), 149 deletions(-) create mode 100644 autogpt_platform/backend/migrations/20250115213557_add_running_balance/migration.sql create mode 100644 autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx create mode 100644 autogpt_platform/frontend/src/hooks/useCredits.ts diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index 75b0daf87d9f..ab7b914a73b5 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -15,6 +15,9 @@ REDIS_PORT=6379 REDIS_PASSWORD=password ENABLE_CREDIT=false +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= + # What environment things should be logged under: local dev or prod APP_ENV=local # What environment to behave as: "local" or "cloud" @@ -36,7 +39,7 @@ SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long ## to use the platform's webhook-related functionality. ## If you are developing locally, you can use something like ngrok to get a publc URL ## and tunnel it to your locally running backend. -PLATFORM_BASE_URL=https://your-public-url-here +PLATFORM_BASE_URL=http://localhost:3000 ## == INTEGRATION CREDENTIALS == ## # Each set of server side credentials is required for the corresponding 3rd party diff --git a/autogpt_platform/backend/backend/blocks/exa/contents.py b/autogpt_platform/backend/backend/blocks/exa/contents.py index fa1e0831d190..a65de53acf69 100644 --- a/autogpt_platform/backend/backend/blocks/exa/contents.py +++ b/autogpt_platform/backend/backend/blocks/exa/contents.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List from pydantic import BaseModel diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index b476f1f0d0b1..c408a7cf0206 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -1,30 +1,32 @@ from abc import ABC, abstractmethod from datetime import datetime, timezone +import stripe from prisma import Json from prisma.enums import CreditTransactionType from prisma.errors import UniqueViolationError -from prisma.models import CreditTransaction +from prisma.models import CreditTransaction, User +from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput +from backend.data import db from backend.data.block import Block, BlockInput, get_block from backend.data.block_cost_config import BLOCK_COSTS from backend.data.cost import BlockCost, BlockCostType -from backend.util.settings import Config +from backend.data.user import get_user_by_id +from backend.util.settings import Settings -config = Config() +settings = Settings() +stripe.api_key = settings.secrets.stripe_api_key class UserCreditBase(ABC): - def __init__(self, num_user_credits_refill: int): - self.num_user_credits_refill = num_user_credits_refill - @abstractmethod - async def get_or_refill_credit(self, user_id: str) -> int: + async def get_credits(self, user_id: str) -> int: """ - Get the current credit for the user and refill if no transaction has been made in the current cycle. + Get the current credits for the user. Returns: - int: The current credit for the user. + int: The current credits for the user. """ pass @@ -32,7 +34,6 @@ async def get_or_refill_credit(self, user_id: str) -> int: async def spend_credits( self, user_id: str, - user_credit: int, block_id: str, input_data: BlockInput, data_size: float, @@ -43,7 +44,6 @@ async def spend_credits( Args: user_id (str): The user ID. - user_credit (int): The current credit for the user. block_id (str): The block ID. input_data (BlockInput): The input data for the block. data_size (float): The size of the data being processed. @@ -57,7 +57,7 @@ async def spend_credits( @abstractmethod async def top_up_credits(self, user_id: str, amount: int): """ - Top up the credits for the user. + Top up the credits for the user immediately. Args: user_id (str): The user ID. @@ -65,51 +65,139 @@ async def top_up_credits(self, user_id: str, amount: int): """ pass + @abstractmethod + async def top_up_intent(self, user_id: str, amount: int) -> str: + """ + Create a payment intent to top up the credits for the user. -class UserCredit(UserCreditBase): - async def get_or_refill_credit(self, user_id: str) -> int: - cur_time = self.time_now() - cur_month = cur_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - nxt_month = ( - cur_month.replace(month=cur_month.month + 1) - if cur_month.month < 12 - else cur_month.replace(year=cur_month.year + 1, month=1) + Args: + user_id (str): The user ID. + amount (int): The amount of credits to top up. + + Returns: + str: The redirect url to the payment page. + """ + pass + + @abstractmethod + async def fulfill_checkout( + self, *, session_id: str | None = None, user_id: str | None = None + ): + """ + Fulfill the Stripe checkout session. + + Args: + session_id (str | None): The checkout session ID. Will try to fulfill most recent if None. + user_id (str | None): The user ID must be provided if session_id is None. + """ + pass + + @staticmethod + def time_now() -> datetime: + return datetime.now(timezone.utc) + + # ====== Transaction Helper Methods ====== # + # Any modifications to the transaction table should only be done through these methods # + + async def _get_credits(self, user_id: str) -> tuple[int, datetime]: + """ + Returns the current balance of the user & the latest balance snapshot time. + """ + top_time = self.time_now() + snapshot = await CreditTransaction.prisma().find_first( + where={ + "userId": user_id, + "createdAt": {"lte": top_time}, + "isActive": True, + "runningBalance": {"not": None}, # type: ignore + }, + order={"createdAt": "desc"}, ) + if snapshot: + return snapshot.runningBalance or 0, snapshot.createdAt - user_credit = await CreditTransaction.prisma().group_by( + # No snapshot: Manually calculate balance using current month's transactions. + low_time = top_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + transactions = await CreditTransaction.prisma().group_by( by=["userId"], sum={"amount": True}, where={ "userId": user_id, - "createdAt": {"gte": cur_month, "lt": nxt_month}, + "createdAt": {"gte": low_time, "lte": top_time}, "isActive": True, }, ) + transaction_balance = ( + transactions[0].get("_sum", {}).get("amount", 0) if transactions else 0 + ) + return transaction_balance, datetime.min - if user_credit: - credit_sum = user_credit[0].get("_sum") or {} - return credit_sum.get("amount", 0) + async def _enable_transaction( + self, transaction_key: str, user_id: str, metadata: Json + ): - key = f"MONTHLY-CREDIT-TOP-UP-{cur_month}" + transaction = await CreditTransaction.prisma().find_first_or_raise( + where={"transactionKey": transaction_key, "userId": user_id} + ) - try: - await CreditTransaction.prisma().create( + if transaction.isActive: + return + + async with db.locked_transaction(f"usr_trx_{user_id}"): + user_balance, _ = await self._get_credits(user_id) + + await CreditTransaction.prisma().update( + where={ + "creditTransactionIdentifier": { + "transactionKey": transaction_key, + "userId": user_id, + } + }, data={ - "amount": self.num_user_credits_refill, - "type": CreditTransactionType.TOP_UP, - "userId": user_id, - "transactionKey": key, + "isActive": True, + "runningBalance": user_balance + transaction.amount, "createdAt": self.time_now(), - } + "metadata": metadata, + }, ) - except UniqueViolationError: - pass # Already refilled this month - return self.num_user_credits_refill + async def _add_transaction( + self, + user_id: str, + amount: int, + transaction_type: CreditTransactionType, + is_active: bool = True, + transaction_key: str | None = None, + block_id: str | None = None, + metadata: Json = Json({}), + ): + async with db.locked_transaction(f"usr_trx_{user_id}"): + # Get latest balance snapshot + user_balance, _ = await self._get_credits(user_id) + if amount < 0 and user_balance < abs(amount): + raise ValueError( + f"Insufficient balance for user {user_id}, balance: {user_balance}, amount: {amount}" + ) - @staticmethod - def time_now(): - return datetime.now(timezone.utc) + # Create the transaction + transaction_data: CreditTransactionCreateInput = { + "userId": user_id, + "amount": amount, + "runningBalance": user_balance + amount, + "type": transaction_type, + "blockId": block_id, + "metadata": metadata, + "isActive": is_active, + "createdAt": self.time_now(), + } + if transaction_key: + transaction_data["transactionKey"] = transaction_key + await CreditTransaction.prisma().create(data=transaction_data) + + return user_balance + amount + + +class UserCredit(UserCreditBase): def _block_usage_cost( self, @@ -148,8 +236,8 @@ def _is_cost_filter_match( ) -> bool: """ Filter rules: - - If costFilter is an object, then check if costFilter is the subset of inputValues - - Otherwise, check if costFilter is equal to inputValues. + - If cost_filter is an object, then check if cost_filter is the subset of input_data + - Otherwise, check if cost_filter is equal to input_data. - Undefined, null, and empty string are considered as equal. """ if not isinstance(cost_filter, dict) or not isinstance(input_data, dict): @@ -164,12 +252,10 @@ def _is_cost_filter_match( async def spend_credits( self, user_id: str, - user_credit: int, block_id: str, input_data: BlockInput, data_size: float, run_time: float, - validate_balance: bool = True, ) -> int: block = get_block(block_id) if not block: @@ -178,42 +264,169 @@ async def spend_credits( cost, matching_filter = self._block_usage_cost( block=block, input_data=input_data, data_size=data_size, run_time=run_time ) - if cost <= 0: + if cost == 0: return 0 - if validate_balance and user_credit < cost: - raise ValueError(f"Insufficient credit: {user_credit} < {cost}") - - await CreditTransaction.prisma().create( - data={ - "userId": user_id, - "amount": -cost, - "type": CreditTransactionType.USAGE, - "blockId": block.id, - "metadata": Json( - { - "block": block.name, - "input": matching_filter, - } - ), - "createdAt": self.time_now(), - } + await self._add_transaction( + user_id=user_id, + amount=-cost, + transaction_type=CreditTransactionType.USAGE, + block_id=block.id, + metadata=Json( + { + "block": block.name, + "input": matching_filter, + } + ), ) + return cost async def top_up_credits(self, user_id: str, amount: int): - await CreditTransaction.prisma().create( - data={ - "userId": user_id, - "amount": amount, - "type": CreditTransactionType.TOP_UP, - "createdAt": self.time_now(), - } + if amount < 0: + raise ValueError(f"Top up amount must not be negative: {amount}") + + await self._add_transaction( + user_id=user_id, + amount=amount, + transaction_type=CreditTransactionType.TOP_UP, + ) + + @staticmethod + async def _get_stripe_customer_id(user_id: str) -> str: + user = await get_user_by_id(user_id) + if not user: + raise ValueError(f"User not found: {user_id}") + + if user.stripeCustomerId: + return user.stripeCustomerId + + customer = stripe.Customer.create(name=user.name or "", email=user.email) + await User.prisma().update( + where={"id": user_id}, data={"stripeCustomerId": customer.id} + ) + return customer.id + + async def top_up_intent(self, user_id: str, amount: int) -> str: + # Create checkout session + # https://docs.stripe.com/checkout/quickstart?client=react + # unit_amount param is always in the smallest currency unit (so cents for usd) + # which is equal to amount of credits + checkout_session = stripe.checkout.Session.create( + customer=await self._get_stripe_customer_id(user_id), + line_items=[ + { + "price_data": { + "currency": "usd", + "product_data": { + "name": "AutoGPT Platform Credits", + }, + "unit_amount": amount, + }, + "quantity": 1, + } + ], + mode="payment", + success_url=settings.config.platform_base_url + + "/store/credits?topup=success", + cancel_url=settings.config.platform_base_url + + "/store/credits?topup=cancel", + ) + + # Create pending transaction + await self._add_transaction( + user_id=user_id, + amount=amount, + transaction_type=CreditTransactionType.TOP_UP, + transaction_key=checkout_session.id, + is_active=False, + metadata=Json({"checkout_session": checkout_session}), + ) + + return checkout_session.url or "" + + # https://docs.stripe.com/checkout/fulfillment + async def fulfill_checkout( + self, *, session_id: str | None = None, user_id: str | None = None + ): + if (not session_id and not user_id) or (session_id and user_id): + raise ValueError("Either session_id or user_id must be provided") + + # Retrieve CreditTransaction + find_filter: CreditTransactionWhereInput = { + "type": CreditTransactionType.TOP_UP, + "isActive": False, + } + if session_id: + find_filter["transactionKey"] = session_id + if user_id: + find_filter["userId"] = user_id + + # Find the most recent inactive top-up transaction + credit_transaction = await CreditTransaction.prisma().find_first_or_raise( + where=find_filter, + order={"createdAt": "desc"}, ) + # This can be called multiple times for one id, so ignore if already fulfilled + if not credit_transaction: + return + + # Retrieve the Checkout Session from the API + checkout_session = stripe.checkout.Session.retrieve( + credit_transaction.transactionKey + ) + + # Check the Checkout Session's payment_status property + # to determine if fulfillment should be performed + if checkout_session.payment_status in ["paid", "no_payment_required"]: + await self._enable_transaction( + transaction_key=credit_transaction.transactionKey, + user_id=credit_transaction.userId, + metadata=Json({"checkout_session": checkout_session}), + ) + + async def get_credits(self, user_id: str) -> int: + balance, _ = await self._get_credits(user_id) + return balance + + +class BetaUserCredit(UserCredit): + """ + This is a temporary class to handle the test user utilizing monthly credit refill. + TODO: Remove this class & its feature toggle. + """ + + def __init__(self, num_user_credits_refill: int): + self.num_user_credits_refill = num_user_credits_refill + + async def get_credits(self, user_id: str) -> int: + cur_time = self.time_now().date() + balance, snapshot_time = await self._get_credits(user_id) + if (snapshot_time.year, snapshot_time.month) == (cur_time.year, cur_time.month): + return balance + + try: + await CreditTransaction.prisma().create( + data={ + "transactionKey": f"MONTHLY-CREDIT-TOP-UP-{cur_time}", + "userId": user_id, + "amount": self.num_user_credits_refill, + "runningBalance": self.num_user_credits_refill, + "type": CreditTransactionType.TOP_UP, + "metadata": Json({}), + "isActive": True, + "createdAt": self.time_now(), + } + ) + except UniqueViolationError: + pass # Already refilled this month + + return self.num_user_credits_refill + class DisabledUserCredit(UserCreditBase): - async def get_or_refill_credit(self, *args, **kwargs) -> int: + async def get_credits(self, *args, **kwargs) -> int: return 0 async def spend_credits(self, *args, **kwargs) -> int: @@ -222,12 +435,21 @@ async def spend_credits(self, *args, **kwargs) -> int: async def top_up_credits(self, *args, **kwargs): pass + async def top_up_intent(self, *args, **kwargs) -> str: + return "" + + async def fulfill_checkout(self, *args, **kwargs): + pass + def get_user_credit_model() -> UserCreditBase: - if config.enable_credit.lower() == "true": - return UserCredit(config.num_user_credits_refill) - else: - return DisabledUserCredit(0) + if not settings.config.enable_credit: + return DisabledUserCredit() + + if settings.config.enable_beta_monthly_credit: + return BetaUserCredit(settings.config.num_user_credits_refill) + + return UserCredit() def get_block_costs() -> dict[str, list[BlockCost]]: diff --git a/autogpt_platform/backend/backend/data/db.py b/autogpt_platform/backend/backend/data/db.py index d18942ccfa27..66d4f167ada9 100644 --- a/autogpt_platform/backend/backend/data/db.py +++ b/autogpt_platform/backend/backend/data/db.py @@ -1,5 +1,6 @@ import logging import os +import zlib from contextlib import asynccontextmanager from uuid import uuid4 @@ -54,6 +55,14 @@ async def transaction(): yield tx +@asynccontextmanager +async def locked_transaction(key: str): + lock_key = zlib.crc32(key.encode("utf-8")) + async with transaction() as tx: + await tx.execute_raw(f"SELECT pg_advisory_xact_lock({lock_key})") + yield tx + + class BaseDbModel(BaseModel): id: str = Field(default_factory=lambda: str(uuid4())) diff --git a/autogpt_platform/backend/backend/executor/database.py b/autogpt_platform/backend/backend/executor/database.py index 4016363c1ab8..108a089acae8 100644 --- a/autogpt_platform/backend/backend/executor/database.py +++ b/autogpt_platform/backend/backend/executor/database.py @@ -78,12 +78,8 @@ def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R: # Credits user_credit_model = get_user_credit_model() - get_or_refill_credit = cast( - Callable[[Any, str], int], - exposed_run_and_wait(user_credit_model.get_or_refill_credit), - ) spend_credits = cast( - Callable[[Any, str, int, str, dict[str, str], float, float], int], + Callable[[Any, str, str, dict[str, str], float, float], int], exposed_run_and_wait(user_credit_model.spend_credits), ) diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index 046da905a11c..33e3ecf67a02 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -183,9 +183,6 @@ def update_execution(status: ExecutionStatus) -> ExecutionResult: output_size = 0 end_status = ExecutionStatus.COMPLETED - credit = db_client.get_or_refill_credit(user_id) - if credit < 0: - raise ValueError(f"Insufficient credit: {credit}") try: for output_name, output_data in node_block.execute( @@ -241,7 +238,7 @@ def update_execution(status: ExecutionStatus) -> ExecutionResult: if res.end_time and res.start_time else 0 ) - db_client.spend_credits(user_id, credit, node_block.id, input_data, s, t) + db_client.spend_credits(user_id, node_block.id, input_data, s, t) # Update execution stats if execution_stats is not None: diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py index 7c554b445c53..14a7925c6b7f 100644 --- a/autogpt_platform/backend/backend/server/model.py +++ b/autogpt_platform/backend/backend/server/model.py @@ -56,3 +56,8 @@ class SetGraphActiveVersion(pydantic.BaseModel): class UpdatePermissionsRequest(pydantic.BaseModel): permissions: List[APIKeyPermission] + + +class RequestTopUp(pydantic.BaseModel): + amount: int + """Amount of credits to top up.""" diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index 9e8bf50d6d9d..1728630a390c 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -4,10 +4,11 @@ from typing import TYPE_CHECKING, Annotated, Any, Sequence import pydantic +import stripe from autogpt_libs.auth.middleware import auth_middleware from autogpt_libs.feature_flag.client import feature_flag from autogpt_libs.utils.cache import thread_cached -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request, Response from typing_extensions import Optional, TypedDict import backend.data.block @@ -40,6 +41,7 @@ CreateAPIKeyRequest, CreateAPIKeyResponse, CreateGraph, + RequestTopUp, SetGraphActiveVersion, UpdatePermissionsRequest, ) @@ -134,7 +136,54 @@ async def get_user_credits( 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)} + return {"credits": max(await _user_credit_model.get_credits(user_id), 0)} + + +@v1_router.post( + path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)] +) +async def request_top_up( + request: RequestTopUp, user_id: Annotated[str, Depends(get_user_id)] +): + checkout_url = await _user_credit_model.top_up_intent(user_id, request.amount) + return {"checkout_url": checkout_url} + + +@v1_router.patch( + path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)] +) +async def fulfill_checkout(user_id: Annotated[str, Depends(get_user_id)]): + await _user_credit_model.fulfill_checkout(user_id=user_id) + return Response(status_code=200) + + +@v1_router.post(path="/credits/stripe_webhook", tags=["credits"]) +async def stripe_webhook(request: Request): + # Get the raw request body + payload = await request.body() + # Get the signature header + sig_header = request.headers.get("stripe-signature") + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.secrets.stripe_webhook_secret + ) + except ValueError: + # Invalid payload + raise HTTPException(status_code=400) + except stripe.SignatureVerificationError: + # Invalid signature + raise HTTPException(status_code=400) + + if ( + event["type"] == "checkout.session.completed" + or event["type"] == "checkout.session.async_payment_succeeded" + ): + await _user_credit_model.fulfill_checkout( + session_id=event["data"]["object"]["id"] + ) + + return Response(status_code=200) ######################################################## diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 399a2d41f7fb..e08b5ca0bf21 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -81,10 +81,14 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): default=True, description="If authentication is enabled or not", ) - enable_credit: str = Field( - default="false", + enable_credit: bool = Field( + default=False, description="If user credit system is enabled or not", ) + enable_beta_monthly_credit: bool = Field( + default=True, + description="If beta monthly credits accounting is enabled or not", + ) num_user_credits_refill: int = Field( default=1500, description="Number of credits to refill for each user", @@ -309,6 +313,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): e2b_api_key: str = Field(default="", description="E2B API key") nvidia_api_key: str = Field(default="", description="Nvidia API key") + stripe_api_key: str = Field(default="", description="Stripe API Key") + stripe_webhook_secret: str = Field(default="", description="Stripe Webhook Secret") + # Add more secret fields as needed model_config = SettingsConfigDict( diff --git a/autogpt_platform/backend/migrations/20250115213557_add_running_balance/migration.sql b/autogpt_platform/backend/migrations/20250115213557_add_running_balance/migration.sql new file mode 100644 index 000000000000..73f8d98f7953 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250115213557_add_running_balance/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CreditTransaction" ADD COLUMN "runningBalance" INTEGER; diff --git a/autogpt_platform/backend/poetry.lock b/autogpt_platform/backend/poetry.lock index be9688866e95..66c666fb09ed 100644 --- a/autogpt_platform/backend/poetry.lock +++ b/autogpt_platform/backend/poetry.lock @@ -3688,6 +3688,22 @@ docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"] release = ["twine"] test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"] +[[package]] +name = "stripe" +version = "11.4.1" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "stripe-11.4.1-py2.py3-none-any.whl", hash = "sha256:8aa47a241de0355c383c916c4ef7273ab666f096a44ee7081e357db4a36f0cce"}, + {file = "stripe-11.4.1.tar.gz", hash = "sha256:7ddd251b622d490fe57d78487855dc9f4d95b1bb113607e81fd377037a133d5a"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + [[package]] name = "supabase" version = "2.11.0" @@ -4432,4 +4448,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "711669de9e6d5b81f19286bd41d52f57bc0177ba8ff5f2b477313a5b2d012ae5" +content-hash = "341712d286b6a6fae89055bd21a55d8fa918973e446f6c0f0329a8493022cbae" diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml index 0573c61c15ff..d12cffed9684 100644 --- a/autogpt_platform/backend/pyproject.toml +++ b/autogpt_platform/backend/pyproject.toml @@ -39,6 +39,7 @@ python-dotenv = "^1.0.1" redis = "^5.2.0" sentry-sdk = "2.19.2" strenum = "^0.4.9" +stripe = "^11.3.0" supabase = "2.11.0" tenacity = "^9.0.0" tweepy = "^4.14.0" diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index ea1865791be0..6d3322240dad 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -393,6 +393,8 @@ model CreditTransaction { amount Int type CreditTransactionType + runningBalance Int? + isActive Boolean @default(true) metadata Json? diff --git a/autogpt_platform/backend/test/data/test_credit.py b/autogpt_platform/backend/test/data/test_credit.py index 41e6a697f92a..007c382d510f 100644 --- a/autogpt_platform/backend/test/data/test_credit.py +++ b/autogpt_platform/backend/test/data/test_credit.py @@ -4,22 +4,27 @@ from prisma.models import CreditTransaction from backend.blocks.llm import AITextGeneratorBlock -from backend.data.credit import UserCredit +from backend.data.credit import BetaUserCredit from backend.data.user import DEFAULT_USER_ID from backend.integrations.credentials_store import openai_credentials from backend.util.test import SpinTestServer REFILL_VALUE = 1000 -user_credit = UserCredit(REFILL_VALUE) +user_credit = BetaUserCredit(REFILL_VALUE) + + +async def disable_test_user_transactions(): + await CreditTransaction.prisma().delete_many(where={"userId": DEFAULT_USER_ID}) @pytest.mark.asyncio(scope="session") async def test_block_credit_usage(server: SpinTestServer): - current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + await disable_test_user_transactions() + await user_credit.top_up_credits(DEFAULT_USER_ID, 100) + current_credit = await user_credit.get_credits(DEFAULT_USER_ID) spending_amount_1 = await user_credit.spend_credits( DEFAULT_USER_ID, - current_credit, AITextGeneratorBlock().id, { "model": "gpt-4-turbo", @@ -31,68 +36,56 @@ async def test_block_credit_usage(server: SpinTestServer): }, 0.0, 0.0, - validate_balance=False, ) assert spending_amount_1 > 0 spending_amount_2 = await user_credit.spend_credits( DEFAULT_USER_ID, - current_credit, AITextGeneratorBlock().id, {"model": "gpt-4-turbo", "api_key": "owned_api_key"}, 0.0, 0.0, - validate_balance=False, ) assert spending_amount_2 == 0 - new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + new_credit = await user_credit.get_credits(DEFAULT_USER_ID) assert new_credit == current_credit - spending_amount_1 - spending_amount_2 @pytest.mark.asyncio(scope="session") async def test_block_credit_top_up(server: SpinTestServer): - current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + await disable_test_user_transactions() + current_credit = await user_credit.get_credits(DEFAULT_USER_ID) await user_credit.top_up_credits(DEFAULT_USER_ID, 100) - new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + new_credit = await user_credit.get_credits(DEFAULT_USER_ID) assert new_credit == current_credit + 100 @pytest.mark.asyncio(scope="session") async def test_block_credit_reset(server: SpinTestServer): - month1 = datetime(2022, 1, 15) - month2 = datetime(2022, 2, 15) + await disable_test_user_transactions() + month1 = 1 + month2 = 2 - user_credit.time_now = lambda: month2 - month2credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + # set the calendar to month 2 but use current time from now + user_credit.time_now = lambda: datetime.now().replace(month=month2) + month2credit = await user_credit.get_credits(DEFAULT_USER_ID) # Month 1 result should only affect month 1 - user_credit.time_now = lambda: month1 - month1credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + user_credit.time_now = lambda: datetime.now().replace(month=month1) + month1credit = await user_credit.get_credits(DEFAULT_USER_ID) await user_credit.top_up_credits(DEFAULT_USER_ID, 100) - assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month1credit + 100 + assert await user_credit.get_credits(DEFAULT_USER_ID) == month1credit + 100 # Month 2 balance is unaffected - user_credit.time_now = lambda: month2 - assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month2credit + user_credit.time_now = lambda: datetime.now().replace(month=month2) + assert await user_credit.get_credits(DEFAULT_USER_ID) == month2credit @pytest.mark.asyncio(scope="session") async def test_credit_refill(server: SpinTestServer): - # Clear all transactions within the month - await CreditTransaction.prisma().update_many( - where={ - "userId": DEFAULT_USER_ID, - "createdAt": { - "gte": datetime(2022, 2, 1), - "lt": datetime(2022, 3, 1), - }, - }, - data={"isActive": False}, - ) - user_credit.time_now = lambda: datetime(2022, 2, 15) - - balance = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) + await disable_test_user_transactions() + balance = await user_credit.get_credits(DEFAULT_USER_ID) assert balance == REFILL_VALUE diff --git a/autogpt_platform/frontend/.env.example b/autogpt_platform/frontend/.env.example index 879858ecfda7..9903c14fe457 100644 --- a/autogpt_platform/frontend/.env.example +++ b/autogpt_platform/frontend/.env.example @@ -5,6 +5,7 @@ NEXT_PUBLIC_AGPT_MARKETPLACE_URL=http://localhost:8015/api/v1/market NEXT_PUBLIC_LAUNCHDARKLY_ENABLED=false NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID= NEXT_PUBLIC_APP_ENV=dev +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= ## Locale settings diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 0ec8cca69c15..98c2a8bd09d8 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", "@sentry/nextjs": "^8", + "@stripe/stripe-js": "^5.3.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.47.8", "@tanstack/react-table": "^8.20.6", diff --git a/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx b/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx new file mode 100644 index 000000000000..f4a8e34a0ba3 --- /dev/null +++ b/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx @@ -0,0 +1,73 @@ +"use client"; +import { Button } from "@/components/agptui/Button"; +import useCredits from "@/hooks/useCredits"; +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function CreditsPage() { + const { credits, requestTopUp } = useCredits(); + const [amount, setAmount] = useState(5); + const [patched, setPatched] = useState(false); + const searchParams = useSearchParams(); + const topupStatus = searchParams.get("topup"); + const api = useBackendAPI(); + + useEffect(() => { + if (!patched && topupStatus === "success") { + api.fulfillCheckout(); + setPatched(true); + } + }, [api, patched, topupStatus]); + + return ( +
+

+ Credits +

+

+ Current credits: {credits} +

+

+ Top-up Credits +

+

+ {topupStatus === "success" && ( + + Your payment was successful. Your credits will be updated shortly. + + )} + {topupStatus === "cancel" && ( + + Payment failed. Your payment method has not been charged. + + )} +

+
+ +
+ setAmount(parseInt(e.target.value))} + /> +
+
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/store/(user)/dashboard/page.tsx b/autogpt_platform/frontend/src/app/store/(user)/dashboard/page.tsx index 375375233cf1..16abc15beea3 100644 --- a/autogpt_platform/frontend/src/app/store/(user)/dashboard/page.tsx +++ b/autogpt_platform/frontend/src/app/store/(user)/dashboard/page.tsx @@ -33,14 +33,14 @@ export default function Page({}: {}) { } catch (error) { console.error("Error fetching submissions:", error); } - }, [api, supabase]); + }, [api]); useEffect(() => { if (!supabase) { return; } fetchData(); - }, [supabase]); + }, [supabase, fetchData]); const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => { setSubmissionData(submission); @@ -56,7 +56,7 @@ export default function Page({}: {}) { api.deleteStoreSubmission(submission_id); fetchData(); }, - [supabase], + [api, supabase, fetchData], ); const onOpenPopout = useCallback(() => { diff --git a/autogpt_platform/frontend/src/app/store/(user)/layout.tsx b/autogpt_platform/frontend/src/app/store/(user)/layout.tsx index 64900562afb2..fa6a66fe95e1 100644 --- a/autogpt_platform/frontend/src/app/store/(user)/layout.tsx +++ b/autogpt_platform/frontend/src/app/store/(user)/layout.tsx @@ -7,6 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { links: [ { text: "Creator Dashboard", href: "/store/dashboard" }, { text: "Agent dashboard", href: "/store/agent-dashboard" }, + { text: "Credits", href: "/store/credits" }, { text: "Integrations", href: "/store/integrations" }, { text: "API Keys", href: "/store/api_keys" }, { text: "Profile", href: "/store/profile" }, diff --git a/autogpt_platform/frontend/src/app/store/search/page.tsx b/autogpt_platform/frontend/src/app/store/search/page.tsx index 2bb57cd4765d..7e3806fcf45e 100644 --- a/autogpt_platform/frontend/src/app/store/search/page.tsx +++ b/autogpt_platform/frontend/src/app/store/search/page.tsx @@ -61,7 +61,7 @@ function SearchResults({ }; fetchData(); - }, [searchTerm, sort]); + }, [api, searchTerm, sort]); const agentsCount = agents.length; const creatorsCount = creators.length; diff --git a/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx b/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx index 545d82b76f64..09267bed7f94 100644 --- a/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx +++ b/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx @@ -8,6 +8,7 @@ import { IconIntegrations, IconProfile, IconSliders, + IconCoin, } from "../ui/icons"; interface SidebarLinkGroup { @@ -22,6 +23,10 @@ interface SidebarProps { } export const Sidebar: React.FC = ({ linkGroups }) => { + const stripeAvailable = Boolean( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + ); + return ( <> @@ -49,6 +54,17 @@ export const Sidebar: React.FC = ({ linkGroups }) => { Creator dashboard + {stripeAvailable && ( + + +
+ Credits +
+ + )} = ({ linkGroups }) => { Agent dashboard + {stripeAvailable && ( + + +
+ Credits +
+ + )} (null); - const api = useBackendAPI(); - - const fetchCredit = useCallback(async () => { - try { - const response = await api.getUserCredit(); - setCredit(response.credits); - } catch (error) { - console.error("Error fetching credit:", error); - setCredit(null); - } - }, []); - - useEffect(() => { - fetchCredit(); - }, [fetchCredit]); + const { credits, fetchCredits } = useCredits(); return ( - credit !== null && ( + credits !== null && ( diff --git a/autogpt_platform/frontend/src/components/ui/icons.tsx b/autogpt_platform/frontend/src/components/ui/icons.tsx index 7764c5fcc870..bc14674bee78 100644 --- a/autogpt_platform/frontend/src/components/ui/icons.tsx +++ b/autogpt_platform/frontend/src/components/ui/icons.tsx @@ -323,7 +323,7 @@ export const IconCoin = createIcon((props) => ( viewBox="0 0 24 24" fill="none" stroke="currentColor" - strokeWidth="2" + strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" aria-label="Coin Icon" diff --git a/autogpt_platform/frontend/src/hooks/useAgentGraph.ts b/autogpt_platform/frontend/src/hooks/useAgentGraph.ts index e275be505bca..a21280f1318b 100644 --- a/autogpt_platform/frontend/src/hooks/useAgentGraph.ts +++ b/autogpt_platform/frontend/src/hooks/useAgentGraph.ts @@ -874,7 +874,7 @@ export default function useAgentGraph( request: "save", state: "saving", }); - }, [saveAgent]); + }, [saveAgent, saveRunRequest.state]); const requestSaveAndRun = useCallback(() => { saveAgent(); diff --git a/autogpt_platform/frontend/src/hooks/useCredits.ts b/autogpt_platform/frontend/src/hooks/useCredits.ts new file mode 100644 index 000000000000..de4ece0a21fd --- /dev/null +++ b/autogpt_platform/frontend/src/hooks/useCredits.ts @@ -0,0 +1,48 @@ +import AutoGPTServerAPI from "@/lib/autogpt-server-api"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { loadStripe } from "@stripe/stripe-js"; +import { useRouter } from "next/navigation"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +export default function useCredits(): { + credits: number | null; + fetchCredits: () => void; + requestTopUp: (usd_amount: number) => Promise; +} { + const [credits, setCredits] = useState(null); + const api = useMemo(() => new AutoGPTServerAPI(), []); + const router = useRouter(); + + const fetchCredits = useCallback(async () => { + const response = await api.getUserCredit(); + setCredits(response.credits); + }, [api]); + + useEffect(() => { + fetchCredits(); + }, [fetchCredits]); + + const requestTopUp = useCallback( + async (usd_amount: number) => { + const stripe = await stripePromise; + + if (!stripe) { + return; + } + + // Convert dollar amount to credit count + const response = await api.requestTopUp(usd_amount * 100); + router.push(response.checkout_url); + }, + [api, router], + ); + + return { + credits, + fetchCredits, + requestTopUp, + }; +} diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts index 169c17ac86b4..5ce92a804268 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts @@ -88,6 +88,14 @@ export default class BackendAPI { } } + requestTopUp(amount: number): Promise<{ checkout_url: string }> { + return this._request("POST", "/credits", { amount }); + } + + fulfillCheckout(): Promise { + return this._request("PATCH", "/credits"); + } + getBlocks(): Promise { return this._get("/blocks"); } diff --git a/autogpt_platform/frontend/yarn.lock b/autogpt_platform/frontend/yarn.lock index e382ca15afc2..f5846929ab0b 100644 --- a/autogpt_platform/frontend/yarn.lock +++ b/autogpt_platform/frontend/yarn.lock @@ -3257,6 +3257,11 @@ resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5" integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw== +"@stripe/stripe-js@^5.3.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.4.0.tgz#847e870ddfe9283432526867857a4c1fba9b11ed" + integrity sha512-3tfMbSvLGB+OsJ2MsjWjWo+7sp29dwx+3+9kG/TEnZQJt+EwbF/Nomm43cSK+6oXZA9uhspgyrB+BbrPRumx4g== + "@supabase/auth-js@2.67.3": version "2.67.3" resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028" From e53f1eaf807d080287bbdd1bd23126c4f6520df8 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Thu, 16 Jan 2025 06:25:08 -0600 Subject: [PATCH 04/38] feat: no longer require ollama key (#9287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes 🏗️ ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [ ] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
--- .../backend/integrations/credentials_store.py | 14 ++++++++++++++ autogpt_platform/frontend/src/app/profile/page.tsx | 1 + .../src/app/store/(user)/integrations/page.tsx | 1 + 3 files changed, 16 insertions(+) diff --git a/autogpt_platform/backend/backend/integrations/credentials_store.py b/autogpt_platform/backend/backend/integrations/credentials_store.py index 3aa7a7fb8a9e..ee1c05bf1ad5 100644 --- a/autogpt_platform/backend/backend/integrations/credentials_store.py +++ b/autogpt_platform/backend/backend/integrations/credentials_store.py @@ -23,6 +23,15 @@ settings = Settings() +# This is an overrride since ollama doesn't actually require an API key, but the creddential system enforces one be attached +ollama_credentials = APIKeyCredentials( + id="744fdc56-071a-4761-b5a5-0af0ce10a2b5", + provider="ollama", + api_key=SecretStr("FAKE_API_KEY"), + title="Use Credits for Ollama", + expires_at=None, +) + revid_credentials = APIKeyCredentials( id="fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", provider="revid", @@ -124,6 +133,7 @@ DEFAULT_CREDENTIALS = [ + ollama_credentials, revid_credentials, ideogram_credentials, replicate_credentials, @@ -169,6 +179,10 @@ def add_creds(self, user_id: str, credentials: Credentials) -> None: def get_all_creds(self, user_id: str) -> list[Credentials]: users_credentials = self._get_user_integrations(user_id).credentials all_credentials = users_credentials + # These will always be added + all_credentials.append(ollama_credentials) + + # These will only be added if the API key is set if settings.secrets.revid_api_key: all_credentials.append(revid_credentials) if settings.secrets.ideogram_api_key: diff --git a/autogpt_platform/frontend/src/app/profile/page.tsx b/autogpt_platform/frontend/src/app/profile/page.tsx index b3097577db3a..d5960c2fdb5f 100644 --- a/autogpt_platform/frontend/src/app/profile/page.tsx +++ b/autogpt_platform/frontend/src/app/profile/page.tsx @@ -98,6 +98,7 @@ export default function PrivatePage() { // This contains ids for built-in "Use Credits for X" credentials const hiddenCredentials = useMemo( () => [ + "744fdc56-071a-4761-b5a5-0af0ce10a2b5", // Ollama "fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", // Revid "760f84fc-b270-42de-91f6-08efe1b512d0", // Ideogram "6b9fc200-4726-4973-86c9-cd526f5ce5db", // Replicate diff --git a/autogpt_platform/frontend/src/app/store/(user)/integrations/page.tsx b/autogpt_platform/frontend/src/app/store/(user)/integrations/page.tsx index a4fa36ab29e3..644c2d9ce10a 100644 --- a/autogpt_platform/frontend/src/app/store/(user)/integrations/page.tsx +++ b/autogpt_platform/frontend/src/app/store/(user)/integrations/page.tsx @@ -98,6 +98,7 @@ export default function PrivatePage() { // This contains ids for built-in "Use Credits for X" credentials const hiddenCredentials = useMemo( () => [ + "744fdc56-071a-4761-b5a5-0af0ce10a2b5", // Ollama "fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", // Revid "760f84fc-b270-42de-91f6-08efe1b512d0", // Ideogram "6b9fc200-4726-4973-86c9-cd526f5ce5db", // Replicate From c36c239dd566074a59d0bfd07bf901dd1d222b55 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Fri, 17 Jan 2025 03:59:49 +0700 Subject: [PATCH 05/38] feat(backend): Add graph/node id & execution id on CreditTransaction table (#9217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to be able to determine the cost of graph/node execution. ### Changes 🏗️ * Add these columns into CreditTransaction `metadata` column: - graph_id - node_id - graph_exec_id - node_exec_id - block_id * Drop the `blockId` column and backfill the dropped value into metadata->>block_id. * Frequent queries on these values will require an index created on demand through a migration, depending on the use case. --------- Co-authored-by: Krzysztof Czerwinski Co-authored-by: Reinier van der Leer --- .../backend/backend/data/credit.py | 29 ++++--- .../backend/backend/data/execution.py | 1 + .../backend/backend/executor/database.py | 3 +- .../backend/backend/executor/manager.py | 21 ++++- .../migration.sql | 35 +++++++++ autogpt_platform/backend/schema.prisma | 78 +++++++++---------- .../backend/test/data/test_credit.py | 37 ++++++--- .../backend/test/test_data_creator.py | 1 - 8 files changed, 129 insertions(+), 76 deletions(-) create mode 100644 autogpt_platform/backend/migrations/20250115382614_reshape_transaction_metadata_column/migration.sql diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index c408a7cf0206..2adab8c40288 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -12,6 +12,7 @@ from backend.data.block import Block, BlockInput, get_block from backend.data.block_cost_config import BLOCK_COSTS from backend.data.cost import BlockCost, BlockCostType +from backend.data.execution import NodeExecutionEntry from backend.data.user import get_user_by_id from backend.util.settings import Settings @@ -33,9 +34,7 @@ async def get_credits(self, user_id: str) -> int: @abstractmethod async def spend_credits( self, - user_id: str, - block_id: str, - input_data: BlockInput, + entry: NodeExecutionEntry, data_size: float, run_time: float, ) -> int: @@ -43,9 +42,7 @@ async def spend_credits( Spend the credits for the user based on the block usage. Args: - user_id (str): The user ID. - block_id (str): The block ID. - input_data (BlockInput): The input data for the block. + entry (NodeExecutionEntry): The node execution identifiers & data. data_size (float): The size of the data being processed. run_time (float): The time taken to run the block. @@ -168,7 +165,6 @@ async def _add_transaction( transaction_type: CreditTransactionType, is_active: bool = True, transaction_key: str | None = None, - block_id: str | None = None, metadata: Json = Json({}), ): async with db.locked_transaction(f"usr_trx_{user_id}"): @@ -185,7 +181,6 @@ async def _add_transaction( "amount": amount, "runningBalance": user_balance + amount, "type": transaction_type, - "blockId": block_id, "metadata": metadata, "isActive": is_active, "createdAt": self.time_now(), @@ -251,29 +246,31 @@ def _is_cost_filter_match( async def spend_credits( self, - user_id: str, - block_id: str, - input_data: BlockInput, + entry: NodeExecutionEntry, data_size: float, run_time: float, ) -> int: - block = get_block(block_id) + block = get_block(entry.block_id) if not block: - raise ValueError(f"Block not found: {block_id}") + raise ValueError(f"Block not found: {entry.block_id}") cost, matching_filter = self._block_usage_cost( - block=block, input_data=input_data, data_size=data_size, run_time=run_time + block=block, input_data=entry.data, data_size=data_size, run_time=run_time ) if cost == 0: return 0 await self._add_transaction( - user_id=user_id, + user_id=entry.user_id, amount=-cost, transaction_type=CreditTransactionType.USAGE, - block_id=block.id, metadata=Json( { + "graph_exec_id": entry.graph_exec_id, + "graph_id": entry.graph_id, + "node_id": entry.node_id, + "node_exec_id": entry.node_exec_id, + "block_id": entry.block_id, "block": block.name, "input": matching_filter, } diff --git a/autogpt_platform/backend/backend/data/execution.py b/autogpt_platform/backend/backend/data/execution.py index 230c8e497034..8df102fca296 100644 --- a/autogpt_platform/backend/backend/data/execution.py +++ b/autogpt_platform/backend/backend/data/execution.py @@ -31,6 +31,7 @@ class NodeExecutionEntry(BaseModel): graph_id: str node_exec_id: str node_id: str + block_id: str data: BlockInput diff --git a/autogpt_platform/backend/backend/executor/database.py b/autogpt_platform/backend/backend/executor/database.py index 108a089acae8..1dee046ccca3 100644 --- a/autogpt_platform/backend/backend/executor/database.py +++ b/autogpt_platform/backend/backend/executor/database.py @@ -4,6 +4,7 @@ from backend.data.credit import get_user_credit_model from backend.data.execution import ( ExecutionResult, + NodeExecutionEntry, RedisExecutionEventBus, create_graph_execution, get_execution_results, @@ -79,7 +80,7 @@ def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R: # Credits user_credit_model = get_user_credit_model() spend_credits = cast( - Callable[[Any, str, str, dict[str, str], float, float], int], + Callable[[Any, NodeExecutionEntry, float, float], int], exposed_run_and_wait(user_credit_model.spend_credits), ) diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index 33e3ecf67a02..50469b50b77c 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -238,7 +238,8 @@ def update_execution(status: ExecutionStatus) -> ExecutionResult: if res.end_time and res.start_time else 0 ) - db_client.spend_credits(user_id, node_block.id, input_data, s, t) + data.data = input_data + db_client.spend_credits(data, s, t) # Update execution stats if execution_stats is not None: @@ -257,7 +258,7 @@ def _enqueue_next_nodes( log_metadata: LogMetadata, ) -> list[NodeExecutionEntry]: def add_enqueued_execution( - node_exec_id: str, node_id: str, data: BlockInput + node_exec_id: str, node_id: str, block_id: str, data: BlockInput ) -> NodeExecutionEntry: exec_update = db_client.update_execution_status( node_exec_id, ExecutionStatus.QUEUED, data @@ -269,6 +270,7 @@ def add_enqueued_execution( graph_id=graph_id, node_exec_id=node_exec_id, node_id=node_id, + block_id=block_id, data=data, ) @@ -322,7 +324,12 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: # Input is complete, enqueue the execution. log_metadata.info(f"Enqueued {suffix}") enqueued_executions.append( - add_enqueued_execution(next_node_exec_id, next_node_id, next_node_input) + add_enqueued_execution( + node_exec_id=next_node_exec_id, + node_id=next_node_id, + block_id=next_node.block_id, + data=next_node_input, + ) ) # Next execution stops here if the link is not static. @@ -352,7 +359,12 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: continue log_metadata.info(f"Enqueueing static-link execution {suffix}") enqueued_executions.append( - add_enqueued_execution(iexec.node_exec_id, next_node_id, idata) + add_enqueued_execution( + node_exec_id=iexec.node_exec_id, + node_id=next_node_id, + block_id=next_node.block_id, + data=idata, + ) ) return enqueued_executions @@ -837,6 +849,7 @@ def add_execution( graph_id=node_exec.graph_id, node_exec_id=node_exec.node_exec_id, node_id=node_exec.node_id, + block_id=node_exec.block_id, data=node_exec.input_data, ) ) diff --git a/autogpt_platform/backend/migrations/20250115382614_reshape_transaction_metadata_column/migration.sql b/autogpt_platform/backend/migrations/20250115382614_reshape_transaction_metadata_column/migration.sql new file mode 100644 index 000000000000..c6952d769bb0 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250115382614_reshape_transaction_metadata_column/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - You are about to drop the column `blockId` on the `CreditTransaction` table. All the data in the column will be moved to metadata->block_id. + +*/ +BEGIN; + +-- DropForeignKey blockId +ALTER TABLE "CreditTransaction" DROP CONSTRAINT "CreditTransaction_blockId_fkey"; + +-- Update migrate blockId into metadata->"block_id" +UPDATE "CreditTransaction" +SET "metadata" = jsonb_set( + COALESCE("metadata"::jsonb, '{}'), + '{block_id}', + to_jsonb("blockId") +) +WHERE "blockId" IS NOT NULL; + +-- AlterTable drop blockId +ALTER TABLE "CreditTransaction" DROP COLUMN "blockId"; + +COMMIT; + +/* + These indices dropped below were part of the cleanup during the schema change applied above. + These indexes were not useful and will not impact anything upon their removal. +*/ + +-- DropIndex +DROP INDEX "StoreListingReview_storeListingVersionId_idx"; + +-- DropIndex +DROP INDEX "StoreListingSubmission_Status_idx"; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 6d3322240dad..a284886d945a 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -32,12 +32,12 @@ model User { AgentPreset AgentPreset[] UserAgent UserAgent[] - Profile Profile[] - StoreListing StoreListing[] - StoreListingReview StoreListingReview[] - StoreListingSubmission StoreListingSubmission[] - APIKeys APIKey[] - IntegrationWebhooks IntegrationWebhook[] + Profile Profile[] + StoreListing StoreListing[] + StoreListingReview StoreListingReview[] + StoreListingSubmission StoreListingSubmission[] + APIKeys APIKey[] + IntegrationWebhooks IntegrationWebhook[] @@index([id]) @@index([email]) @@ -64,23 +64,23 @@ model AgentGraph { AgentNodes AgentNode[] AgentGraphExecution AgentGraphExecution[] - AgentPreset AgentPreset[] - UserAgent UserAgent[] - StoreListing StoreListing[] - StoreListingVersion StoreListingVersion? + AgentPreset AgentPreset[] + UserAgent UserAgent[] + StoreListing StoreListing[] + StoreListingVersion StoreListingVersion? @@id(name: "graphVersionId", [id, version]) @@index([userId, isActive]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// //////////////// 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 +// 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 { @@ -102,9 +102,9 @@ model AgentPreset { agentVersion Int Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade) - InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData") - UserAgents UserAgent[] - AgentExecution AgentGraphExecution[] + InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData") + UserAgents UserAgent[] + AgentExecution AgentGraphExecution[] @@index([userId]) } @@ -134,11 +134,11 @@ model UserAgent { @@index([userId]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// //////// AGENT DEFINITION AND EXECUTION TABLES //////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// // This model describes a single node in the Agent Graph/Flow (Multi Agent System). model AgentNode { @@ -207,7 +207,6 @@ model AgentBlock { // Prisma requires explicit back-references. ReferencedByAgentNode AgentNode[] - CreditTransaction CreditTransaction[] } // This model describes the status of an AgentGraphExecution or AgentNodeExecution. @@ -345,11 +344,11 @@ model AnalyticsDetails { @@index([type]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// ////////////// METRICS TRACKING TABLES //////////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// model AnalyticsMetrics { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -375,11 +374,11 @@ enum CreditTransactionType { USAGE } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// //////// ACCOUNTING AND CREDIT SYSTEM TABLES ////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// model CreditTransaction { transactionKey String @default(uuid()) createdAt DateTime @default(now()) @@ -387,9 +386,6 @@ model CreditTransaction { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - blockId String? - block AgentBlock? @relation(fields: [blockId], references: [id]) - amount Int type CreditTransactionType @@ -402,11 +398,11 @@ model CreditTransaction { @@index([userId, createdAt]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// ////////////// Store TABLES /////////////////////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// model Profile { id String @id @default(uuid()) @@ -414,7 +410,7 @@ model Profile { updatedAt DateTime @default(now()) @updatedAt // Only 1 of user or group can be set. - // The user this profile belongs to, if any. + // The user this profile belongs to, if any. userId String? User User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -528,7 +524,7 @@ model StoreListingVersion { 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, + // The details 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 @@ -573,7 +569,6 @@ model StoreListingReview { comments String? @@unique([storeListingVersionId, reviewByUserId]) - @@index([storeListingVersionId]) } enum SubmissionStatus { @@ -601,7 +596,6 @@ model StoreListingSubmission { reviewComments String? @@index([storeListingId]) - @@index([Status]) } enum APIKeyPermission { diff --git a/autogpt_platform/backend/test/data/test_credit.py b/autogpt_platform/backend/test/data/test_credit.py index 007c382d510f..9007cfea52d9 100644 --- a/autogpt_platform/backend/test/data/test_credit.py +++ b/autogpt_platform/backend/test/data/test_credit.py @@ -5,6 +5,7 @@ from backend.blocks.llm import AITextGeneratorBlock from backend.data.credit import BetaUserCredit +from backend.data.execution import NodeExecutionEntry from backend.data.user import DEFAULT_USER_ID from backend.integrations.credentials_store import openai_credentials from backend.util.test import SpinTestServer @@ -24,25 +25,37 @@ async def test_block_credit_usage(server: SpinTestServer): current_credit = await user_credit.get_credits(DEFAULT_USER_ID) spending_amount_1 = await user_credit.spend_credits( - DEFAULT_USER_ID, - AITextGeneratorBlock().id, - { - "model": "gpt-4-turbo", - "credentials": { - "id": openai_credentials.id, - "provider": openai_credentials.provider, - "type": openai_credentials.type, + NodeExecutionEntry( + user_id=DEFAULT_USER_ID, + graph_id="test_graph", + node_id="test_node", + graph_exec_id="test_graph_exec", + node_exec_id="test_node_exec", + block_id=AITextGeneratorBlock().id, + data={ + "model": "gpt-4-turbo", + "credentials": { + "id": openai_credentials.id, + "provider": openai_credentials.provider, + "type": openai_credentials.type, + }, }, - }, + ), 0.0, 0.0, ) assert spending_amount_1 > 0 spending_amount_2 = await user_credit.spend_credits( - DEFAULT_USER_ID, - AITextGeneratorBlock().id, - {"model": "gpt-4-turbo", "api_key": "owned_api_key"}, + NodeExecutionEntry( + user_id=DEFAULT_USER_ID, + graph_id="test_graph", + node_id="test_node", + graph_exec_id="test_graph_exec", + node_exec_id="test_node_exec", + block_id=AITextGeneratorBlock().id, + data={"model": "gpt-4-turbo", "api_key": "owned_api_key"}, + ), 0.0, 0.0, ) diff --git a/autogpt_platform/backend/test/test_data_creator.py b/autogpt_platform/backend/test/test_data_creator.py index 1f79386cc6e7..d5235043d355 100644 --- a/autogpt_platform/backend/test/test_data_creator.py +++ b/autogpt_platform/backend/test/test_data_creator.py @@ -298,7 +298,6 @@ async def main(): data={ "transactionKey": str(faker.uuid4()), "userId": user.id, - "blockId": block.id, "amount": random.randint(1, 100), "type": ( prisma.enums.CreditTransactionType.TOP_UP From 56b33327ab8cbd8820a9f1e308bad7e413841df8 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Fri, 17 Jan 2025 04:00:15 +0700 Subject: [PATCH 06/38] feat(platform): Add billing portal entry point (#9264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image ### Changes 🏗️ Added an entry point to open the Stripe billing portal. ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [ ] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
--------- Co-authored-by: Krzysztof Czerwinski --- .../backend/backend/data/credit.py | 32 ++--- .../backend/backend/server/routers/v1.py | 21 +++- .../src/app/store/(user)/credits/page.tsx | 117 +++++++++++------- .../src/lib/autogpt-server-api/client.ts | 4 + 4 files changed, 114 insertions(+), 60 deletions(-) diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index 2adab8c40288..fe76b9c21634 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -289,28 +289,13 @@ async def top_up_credits(self, user_id: str, amount: int): transaction_type=CreditTransactionType.TOP_UP, ) - @staticmethod - async def _get_stripe_customer_id(user_id: str) -> str: - user = await get_user_by_id(user_id) - if not user: - raise ValueError(f"User not found: {user_id}") - - if user.stripeCustomerId: - return user.stripeCustomerId - - customer = stripe.Customer.create(name=user.name or "", email=user.email) - await User.prisma().update( - where={"id": user_id}, data={"stripeCustomerId": customer.id} - ) - return customer.id - async def top_up_intent(self, user_id: str, amount: int) -> str: # Create checkout session # https://docs.stripe.com/checkout/quickstart?client=react # unit_amount param is always in the smallest currency unit (so cents for usd) # which is equal to amount of credits checkout_session = stripe.checkout.Session.create( - customer=await self._get_stripe_customer_id(user_id), + customer=await get_stripe_customer_id(user_id), line_items=[ { "price_data": { @@ -451,3 +436,18 @@ def get_user_credit_model() -> UserCreditBase: def get_block_costs() -> dict[str, list[BlockCost]]: return {block().id: costs for block, costs in BLOCK_COSTS.items()} + + +async def get_stripe_customer_id(user_id: str) -> str: + user = await get_user_by_id(user_id) + if not user: + raise ValueError(f"User not found: {user_id}") + + if user.stripeCustomerId: + return user.stripeCustomerId + + customer = stripe.Customer.create(name=user.name or "", email=user.email) + await User.prisma().update( + where={"id": user_id}, data={"stripeCustomerId": customer.id} + ) + return customer.id diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index 1728630a390c..f03439a5f866 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -29,7 +29,11 @@ update_api_key_permissions, ) from backend.data.block import BlockInput, CompletedBlockOutput -from backend.data.credit import get_block_costs, get_user_credit_model +from backend.data.credit import ( + get_block_costs, + get_stripe_customer_id, + get_user_credit_model, +) from backend.data.user import get_or_create_user from backend.executor import ExecutionManager, ExecutionScheduler, scheduler from backend.integrations.creds_manager import IntegrationCredentialsManager @@ -186,6 +190,21 @@ async def stripe_webhook(request: Request): return Response(status_code=200) +@v1_router.get(path="/credits/manage", dependencies=[Depends(auth_middleware)]) +async def manage_payment_method( + user_id: Annotated[str, Depends(get_user_id)], +) -> dict[str, str]: + session = stripe.billing_portal.Session.create( + customer=await get_stripe_customer_id(user_id), + return_url=settings.config.platform_base_url + "/store/credits", + ) + if not session: + raise HTTPException( + status_code=400, detail="Failed to create billing portal session" + ) + return {"url": session.url} + + ######################################################## ##################### Graphs ########################### ######################################################## diff --git a/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx b/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx index f4a8e34a0ba3..3a02adc076b7 100644 --- a/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx +++ b/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx @@ -2,14 +2,15 @@ import { Button } from "@/components/agptui/Button"; import useCredits from "@/hooks/useCredits"; import { useBackendAPI } from "@/lib/autogpt-server-api/context"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; export default function CreditsPage() { - const { credits, requestTopUp } = useCredits(); + const { requestTopUp } = useCredits(); const [amount, setAmount] = useState(5); const [patched, setPatched] = useState(false); const searchParams = useSearchParams(); + const router = useRouter(); const topupStatus = searchParams.get("topup"); const api = useBackendAPI(); @@ -20,54 +21,84 @@ export default function CreditsPage() { } }, [api, patched, topupStatus]); + const openBillingPortal = async () => { + const portal = await api.getUserPaymentPortalLink(); + router.push(portal.url); + }; + return (

Credits

-

- Current credits: {credits} -

-

- Top-up Credits -

-

- {topupStatus === "success" && ( - - Your payment was successful. Your credits will be updated shortly. - - )} - {topupStatus === "cancel" && ( - - Payment failed. Your payment method has not been charged. - - )} -

-
- -
- setAmount(parseInt(e.target.value))} - /> + +
+ {/* Left Column */} +
+

Top-up Credits

+ +

+ {topupStatus === "success" && ( + + Your payment was successful. Your credits will be updated + shortly. + + )} + {topupStatus === "cancel" && ( + + Payment failed. Your payment method has not been charged. + + )} +

+ +
+ +
+ setAmount(parseInt(e.target.value))} + /> +
+
+ + +
+ + {/* Right Column */} +
+

Manage Your Payment Methods

+
+

+ You can manage your cards and see your payment history in the + billing portal. +

+
+ +
-
); } diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts index 5ce92a804268..002da433e4ee 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts @@ -92,6 +92,10 @@ export default class BackendAPI { return this._request("POST", "/credits", { amount }); } + getUserPaymentPortalLink(): Promise<{ url: string }> { + return this._get("/credits/manage"); + } + fulfillCheckout(): Promise { return this._request("PATCH", "/credits"); } From 3c30783b144918f02dee511a5a28d6dfd2a0394e Mon Sep 17 00:00:00 2001 From: Bently Date: Thu, 16 Jan 2025 22:35:05 +0000 Subject: [PATCH 07/38] docs(Ollama): Remove steps about adding ollama credentials (#9288) Since ["feat: no longer require ollama key #9287"](https://github.com/Significant-Gravitas/AutoGPT/pull/9287) we no longer need the steps for adding ollama credentials so this removes them from the docs --- .../imgs/ollama/Ollama-Select-Llama32.png | Bin 107840 -> 83379 bytes docs/content/platform/ollama.md | 8 +------- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/content/imgs/ollama/Ollama-Select-Llama32.png b/docs/content/imgs/ollama/Ollama-Select-Llama32.png index fc9b58d6a9471f76cd7ab5413d3e657c54bcaed3..cd39e1b4e6cbe94d2bdefa2478d0c96311c5cf91 100644 GIT binary patch literal 83379 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 literal 107840 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 Date: Fri, 17 Jan 2025 11:44:04 +0000 Subject: [PATCH 08/38] feat(platform): Create external API (#9272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to allow external api calls against our platform We also want to keep it sep from internal platform calls for dev ex, security and scale seperation of concerns ### Changes 🏗️ This PR adds the required external routes It mounts the new routes on the same app Infra PR will seprate routing and domains ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [ ] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
--- .../backend/backend/data/execution.py | 27 ++++- .../backend/backend/executor/manager.py | 4 +- .../backend/backend/server/external/api.py | 11 ++ .../backend/server/external/middleware.py | 37 ++++++ .../server/external/routes/__init__.py | 0 .../backend/server/external/routes/v1.py | 111 ++++++++++++++++++ .../backend/backend/server/rest_api.py | 3 + .../backend/backend/server/routers/v1.py | 3 - .../backend/test/executor/test_manager.py | 2 +- 9 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 autogpt_platform/backend/backend/server/external/api.py create mode 100644 autogpt_platform/backend/backend/server/external/middleware.py create mode 100644 autogpt_platform/backend/backend/server/external/routes/__init__.py create mode 100644 autogpt_platform/backend/backend/server/external/routes/v1.py diff --git a/autogpt_platform/backend/backend/data/execution.py b/autogpt_platform/backend/backend/data/execution.py index 8df102fca296..714ea0f51c6c 100644 --- a/autogpt_platform/backend/backend/data/execution.py +++ b/autogpt_platform/backend/backend/data/execution.py @@ -1,9 +1,10 @@ from collections import defaultdict from datetime import datetime, timezone from multiprocessing import Manager -from typing import Any, AsyncGenerator, Generator, Generic, TypeVar +from typing import Any, AsyncGenerator, Generator, Generic, Optional, TypeVar from prisma.enums import AgentExecutionStatus +from prisma.errors import PrismaError from prisma.models import ( AgentGraphExecution, AgentNodeExecution, @@ -325,6 +326,30 @@ async def update_execution_status( return ExecutionResult.from_db(res) +async def get_execution( + execution_id: str, user_id: str +) -> Optional[AgentNodeExecution]: + """ + Get an execution by ID. Returns None if not found. + + Args: + execution_id: The ID of the execution to retrieve + + Returns: + The execution if found, None otherwise + """ + try: + execution = await AgentNodeExecution.prisma().find_unique( + where={ + "id": execution_id, + "userId": user_id, + } + ) + return execution + except PrismaError: + return None + + async def get_execution_results(graph_exec_id: str) -> list[ExecutionResult]: executions = await AgentNodeExecution.prisma().find_many( where={"agentGraphExecutionId": graph_exec_id}, diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index 50469b50b77c..9ea8c9f76d4c 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -812,8 +812,8 @@ def add_execution( # Extract request input data, and assign it to the input pin. if block.block_type == BlockType.INPUT: name = node.input_default.get("name") - if name and name in data: - input_data = {"value": data[name]} + if name in data.get("node_input", {}): + input_data = {"value": data["node_input"][name]} # Extract webhook payload, and assign it to the input pin webhook_payload_key = f"webhook_{node.webhook_id}_payload" diff --git a/autogpt_platform/backend/backend/server/external/api.py b/autogpt_platform/backend/backend/server/external/api.py new file mode 100644 index 000000000000..3236766fddf1 --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/api.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI + +from .routes.v1 import v1_router + +external_app = FastAPI( + title="AutoGPT External API", + description="External API for AutoGPT integrations", + docs_url="/docs", + version="1.0", +) +external_app.include_router(v1_router, prefix="/v1") diff --git a/autogpt_platform/backend/backend/server/external/middleware.py b/autogpt_platform/backend/backend/server/external/middleware.py new file mode 100644 index 000000000000..2878e3d310d1 --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/middleware.py @@ -0,0 +1,37 @@ +from fastapi import Depends, HTTPException, Request +from fastapi.security import APIKeyHeader +from prisma.enums import APIKeyPermission + +from backend.data.api_key import has_permission, validate_api_key + +api_key_header = APIKeyHeader(name="X-API-Key") + + +async def require_api_key(request: Request): + """Base middleware for API key authentication""" + api_key = await api_key_header(request) + + if api_key is None: + raise HTTPException(status_code=401, detail="Missing API key") + + api_key_obj = await validate_api_key(api_key) + + if not api_key_obj: + raise HTTPException(status_code=401, detail="Invalid API key") + + request.state.api_key = api_key_obj + return api_key_obj + + +def require_permission(permission: APIKeyPermission): + """Dependency function for checking specific permissions""" + + async def check_permission(api_key=Depends(require_api_key)): + if not has_permission(api_key, permission): + raise HTTPException( + status_code=403, + detail=f"API key missing required permission: {permission}", + ) + return api_key + + return check_permission diff --git a/autogpt_platform/backend/backend/server/external/routes/__init__.py b/autogpt_platform/backend/backend/server/external/routes/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/server/external/routes/v1.py b/autogpt_platform/backend/backend/server/external/routes/v1.py new file mode 100644 index 000000000000..ecc64a60375d --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/routes/v1.py @@ -0,0 +1,111 @@ +import logging +from collections import defaultdict +from typing import Any, Sequence + +from autogpt_libs.utils.cache import thread_cached +from fastapi import APIRouter, Depends, HTTPException +from prisma.enums import APIKeyPermission + +import backend.data.block +from backend.data import execution as execution_db +from backend.data import graph as graph_db +from backend.data.api_key import APIKey +from backend.data.block import BlockInput, CompletedBlockOutput +from backend.executor import ExecutionManager +from backend.server.external.middleware import require_permission +from backend.util.service import get_service_client +from backend.util.settings import Settings + + +@thread_cached +def execution_manager_client() -> ExecutionManager: + return get_service_client(ExecutionManager) + + +settings = Settings() +logger = logging.getLogger(__name__) + +v1_router = APIRouter() + + +@v1_router.get( + path="/blocks", + tags=["blocks"], + dependencies=[Depends(require_permission(APIKeyPermission.READ_BLOCK))], +) +def get_graph_blocks() -> Sequence[dict[Any, Any]]: + blocks = [block() for block in backend.data.block.get_blocks().values()] + return [b.to_dict() for b in blocks] + + +@v1_router.post( + path="/blocks/{block_id}/execute", + tags=["blocks"], + dependencies=[Depends(require_permission(APIKeyPermission.EXECUTE_BLOCK))], +) +def execute_graph_block( + block_id: str, + data: BlockInput, + api_key: APIKey = Depends(require_permission(APIKeyPermission.EXECUTE_BLOCK)), +) -> CompletedBlockOutput: + obj = backend.data.block.get_block(block_id) + if not obj: + raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") + + output = defaultdict(list) + for name, data in obj.execute(data): + output[name].append(data) + return output + + +@v1_router.post( + path="/graphs/{graph_id}/execute", + tags=["graphs"], +) +def execute_graph( + graph_id: str, + node_input: dict[Any, Any], + api_key: APIKey = Depends(require_permission(APIKeyPermission.EXECUTE_GRAPH)), +) -> dict[str, Any]: + try: + graph_exec = execution_manager_client().add_execution( + graph_id, node_input, user_id=api_key.user_id + ) + return {"id": graph_exec.graph_exec_id} + except Exception as e: + msg = e.__str__().encode().decode("unicode_escape") + raise HTTPException(status_code=400, detail=msg) + + +@v1_router.get( + path="/graphs/{graph_id}/executions/{graph_exec_id}/results", + tags=["graphs"], +) +async def get_graph_execution_results( + graph_id: str, + graph_exec_id: str, + api_key: APIKey = Depends(require_permission(APIKeyPermission.READ_GRAPH)), +) -> dict: + graph = await graph_db.get_graph(graph_id, user_id=api_key.user_id) + if not graph: + raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") + + results = await execution_db.get_execution_results(graph_exec_id) + + return { + "execution_id": graph_exec_id, + "nodes": [ + { + "node_id": result.node_id, + "input": ( + result.input_data.get("value") + if "value" in result.input_data + else result.input_data + ), + "output": result.output_data.get( + "response", result.output_data.get("result", []) + ), + } + for result in results + ], + } diff --git a/autogpt_platform/backend/backend/server/rest_api.py b/autogpt_platform/backend/backend/server/rest_api.py index c5be1c179260..b5124e0c0aae 100644 --- a/autogpt_platform/backend/backend/server/rest_api.py +++ b/autogpt_platform/backend/backend/server/rest_api.py @@ -20,6 +20,7 @@ import backend.server.v2.store.routes import backend.util.service import backend.util.settings +from backend.server.external.api import external_app settings = backend.util.settings.Settings() logger = logging.getLogger(__name__) @@ -94,6 +95,8 @@ def handler(request: fastapi.Request, exc: Exception): backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library" ) +app.mount("/external-api", external_app) + @app.get(path="/health", tags=["health"], dependencies=[]) async def health(): diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index f03439a5f866..2b1b76d651b2 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -613,7 +613,6 @@ def get_execution_schedules( tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def create_api_key( request: CreateAPIKeyRequest, user_id: Annotated[str, Depends(get_user_id)] ) -> CreateAPIKeyResponse: @@ -637,7 +636,6 @@ async def create_api_key( tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def get_api_keys( user_id: Annotated[str, Depends(get_user_id)] ) -> list[APIKeyWithoutHash]: @@ -655,7 +653,6 @@ async def get_api_keys( tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def get_api_key( key_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> APIKeyWithoutHash: diff --git a/autogpt_platform/backend/test/executor/test_manager.py b/autogpt_platform/backend/test/executor/test_manager.py index 9bcd04a08aee..1f69defc8426 100644 --- a/autogpt_platform/backend/test/executor/test_manager.py +++ b/autogpt_platform/backend/test/executor/test_manager.py @@ -125,7 +125,7 @@ async def test_agent_execution(server: SpinTestServer): logger.info("Starting test_agent_execution") test_user = await create_test_user() test_graph = await create_graph(server, create_test_graph(), test_user) - data = {"input_1": "Hello", "input_2": "World"} + data = {"node_input": {"input_1": "Hello", "input_2": "World"}} graph_exec_id = await execute_graph( server.agent_server, test_graph, From 0d2bb4678681ac36edd70fe73f8206866fbfa855 Mon Sep 17 00:00:00 2001 From: Reinier van der Leer Date: Fri, 17 Jan 2025 14:29:43 +0100 Subject: [PATCH 09/38] fix(frontend): Unbreak save button after save error (#9290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolves #9253 ### Changes 🏗️ - Update state when an error occurs on save, to re-enable the save button ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - Try to save an agent with missing required fields -> should give an error - Fill out the required fields and try saving again -> should work --- autogpt_platform/frontend/src/hooks/useAgentGraph.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/autogpt_platform/frontend/src/hooks/useAgentGraph.ts b/autogpt_platform/frontend/src/hooks/useAgentGraph.ts index a21280f1318b..c2910f2c51b5 100644 --- a/autogpt_platform/frontend/src/hooks/useAgentGraph.ts +++ b/autogpt_platform/frontend/src/hooks/useAgentGraph.ts @@ -862,6 +862,7 @@ export default function useAgentGraph( title: "Error saving agent", description: errorMessage, }); + setSaveRunRequest({ request: "save", state: "error" }); } }, [_saveAgent, toast]); From 56612f16cfb28fc2426b84831ba992b19c808476 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Fri, 17 Jan 2025 07:35:58 -0600 Subject: [PATCH 10/38] feat(platform): Linear integration (#9269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I want to be able to do stuff with linear automatically ### Changes 🏗️ - Adds all the backing details to add linear auth and API access with oauth (and prep for API key) ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [ ] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
--------- Co-authored-by: Aarushi <50577581+aarushik93@users.noreply.github.com> --- autogpt_platform/backend/.env.example | 6 + .../backend/backend/blocks/basic.py | 46 +++ .../backend/backend/blocks/linear/_api.py | 272 ++++++++++++++++++ .../backend/backend/blocks/linear/_auth.py | 101 +++++++ .../backend/backend/blocks/linear/comment.py | 81 ++++++ .../backend/backend/blocks/linear/issues.py | 186 ++++++++++++ .../backend/backend/blocks/linear/models.py | 41 +++ .../backend/backend/blocks/linear/projects.py | 93 ++++++ .../backend/backend/blocks/linear/triggers.py | 0 .../backend/backend/data/block.py | 2 + .../backend/integrations/oauth/__init__.py | 2 + .../backend/integrations/oauth/linear.py | 165 +++++++++++ .../backend/backend/integrations/providers.py | 1 + .../backend/server/integrations/router.py | 5 + .../backend/backend/util/settings.py | 3 + .../integrations/credentials-input.tsx | 4 +- .../integrations/credentials-provider.tsx | 1 + .../src/lib/autogpt-server-api/types.ts | 1 + 18 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 autogpt_platform/backend/backend/blocks/linear/_api.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/_auth.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/comment.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/issues.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/models.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/projects.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/triggers.py create mode 100644 autogpt_platform/backend/backend/integrations/oauth/linear.py diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index ab7b914a73b5..838cfdb283a2 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -75,6 +75,12 @@ GOOGLE_CLIENT_SECRET= TWITTER_CLIENT_ID= TWITTER_CLIENT_SECRET= +# Linear App +# Make a new workspace for your OAuth APP -- trust me +# https://linear.app/settings/api/applications/new +# Callback URL: http://localhost:3000/auth/integrations/oauth_callback +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= ## ===== OPTIONAL API KEYS ===== ## diff --git a/autogpt_platform/backend/backend/blocks/basic.py b/autogpt_platform/backend/backend/blocks/basic.py index b68c04bad1eb..d7dd468c408c 100644 --- a/autogpt_platform/backend/backend/blocks/basic.py +++ b/autogpt_platform/backend/backend/blocks/basic.py @@ -1,9 +1,11 @@ +import enum from typing import Any, List from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType from backend.data.model import SchemaField from backend.util.mock import MockObject from backend.util.text import TextFormatter +from backend.util.type import convert formatter = TextFormatter() @@ -590,3 +592,47 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "list", input_data.values except Exception as e: yield "error", f"Failed to create list: {str(e)}" + + +class TypeOptions(enum.Enum): + STRING = "string" + NUMBER = "number" + BOOLEAN = "boolean" + LIST = "list" + DICTIONARY = "dictionary" + + +class UniversalTypeConverterBlock(Block): + class Input(BlockSchema): + value: Any = SchemaField( + description="The value to convert to a universal type." + ) + type: TypeOptions = SchemaField(description="The type to convert the value to.") + + class Output(BlockSchema): + value: Any = SchemaField(description="The converted value.") + + def __init__(self): + super().__init__( + id="95d1b990-ce13-4d88-9737-ba5c2070c97b", + description="This block is used to convert a value to a universal type.", + categories={BlockCategory.BASIC}, + input_schema=UniversalTypeConverterBlock.Input, + output_schema=UniversalTypeConverterBlock.Output, + ) + + def run(self, input_data: Input, **kwargs) -> BlockOutput: + try: + converted_value = convert( + input_data.value, + { + TypeOptions.STRING: str, + TypeOptions.NUMBER: float, + TypeOptions.BOOLEAN: bool, + TypeOptions.LIST: list, + TypeOptions.DICTIONARY: dict, + }[input_data.type], + ) + yield "value", converted_value + except Exception as e: + yield "error", f"Failed to convert value: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/_api.py b/autogpt_platform/backend/backend/blocks/linear/_api.py new file mode 100644 index 000000000000..4639975e8eba --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_api.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +from backend.blocks.linear._auth import LinearCredentials +from backend.blocks.linear.models import ( + CreateCommentResponse, + CreateIssueResponse, + Issue, + Project, +) +from backend.util.request import Requests + + +class LinearAPIException(Exception): + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class LinearClient: + """Client for the Linear API + + If you're looking for the schema: https://studio.apollographql.com/public/Linear-API/variant/current/schema + """ + + API_URL = "https://api.linear.app/graphql" + + def __init__( + self, + credentials: LinearCredentials | None = None, + custom_requests: Optional[Requests] = None, + ): + if custom_requests: + self._requests = custom_requests + else: + + headers: Dict[str, str] = { + "Content-Type": "application/json", + } + if credentials: + headers["Authorization"] = credentials.bearer() + + self._requests = Requests( + extra_headers=headers, + trusted_origins=["https://api.linear.app"], + raise_for_status=False, + ) + + def _execute_graphql_request( + self, query: str, variables: dict | None = None + ) -> Any: + """ + Executes a GraphQL request against the Linear API and returns the response data. + + Args: + query: The GraphQL query string. + variables (optional): Any GraphQL query variables + + Returns: + The parsed JSON response data, or raises a LinearAPIException on error. + """ + payload: Dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + + response = self._requests.post(self.API_URL, json=payload) + + if not response.ok: + + try: + error_data = response.json() + error_message = error_data.get("errors", [{}])[0].get("message", "") + except json.JSONDecodeError: + error_message = response.text + + raise LinearAPIException( + f"Linear API request failed ({response.status_code}): {error_message}", + response.status_code, + ) + + response_data = response.json() + if "errors" in response_data: + + error_messages = [ + error.get("message", "") for error in response_data["errors"] + ] + raise LinearAPIException( + f"Linear API returned errors: {', '.join(error_messages)}", + response.status_code, + ) + + return response_data["data"] + + def query(self, query: str, variables: Optional[dict] = None) -> dict: + """Executes a GraphQL query. + + Args: + query: The GraphQL query string. + variables: Query variables, if any. + + Returns: + The response data. + """ + return self._execute_graphql_request(query, variables) + + def mutate(self, mutation: str, variables: Optional[dict] = None) -> dict: + """Executes a GraphQL mutation. + + Args: + mutation: The GraphQL mutation string. + variables: Query variables, if any. + + Returns: + The response data. + """ + return self._execute_graphql_request(mutation, variables) + + def try_create_comment(self, issue_id: str, comment: str) -> CreateCommentResponse: + try: + mutation = """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + body + } + } + } + """ + + variables = { + "input": { + "body": comment, + "issueId": issue_id, + } + } + + added_comment = self.mutate(mutation, variables) + # Select the commentCreate field from the mutation response + return CreateCommentResponse(**added_comment["commentCreate"]) + except LinearAPIException as e: + raise e + + def try_get_team_by_name(self, team_name: str) -> str: + try: + query = """ + query GetTeamId($searchTerm: String!) { + teams(filter: { + or: [ + { name: { eqIgnoreCase: $searchTerm } }, + { key: { eqIgnoreCase: $searchTerm } } + ] + }) { + nodes { + id + name + key + } + } + } + """ + + variables: dict[str, Any] = { + "searchTerm": team_name, + } + + team_id = self.query(query, variables) + return team_id["teams"]["nodes"][0]["id"] + except LinearAPIException as e: + raise e + + def try_create_issue( + self, + team_id: str, + title: str, + description: str | None = None, + priority: int | None = None, + project_id: str | None = None, + ) -> CreateIssueResponse: + try: + mutation = """ + mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + issue { + title + description + id + identifier + priority + } + } + } + """ + + variables: dict[str, Any] = { + "input": { + "teamId": team_id, + "title": title, + } + } + + if project_id: + variables["input"]["projectId"] = project_id + + if description: + variables["input"]["description"] = description + + if priority: + variables["input"]["priority"] = priority + + added_issue = self.mutate(mutation, variables) + return CreateIssueResponse(**added_issue["issueCreate"]) + except LinearAPIException as e: + raise e + + def try_search_projects(self, term: str) -> list[Project]: + try: + query = """ + query SearchProjects($term: String!, $includeComments: Boolean!) { + searchProjects(term: $term, includeComments: $includeComments) { + nodes { + id + name + description + priority + progress + content + } + } + } + """ + + variables: dict[str, Any] = { + "term": term, + "includeComments": True, + } + + projects = self.query(query, variables) + return [ + Project(**project) for project in projects["searchProjects"]["nodes"] + ] + except LinearAPIException as e: + raise e + + def try_search_issues(self, term: str) -> list[Issue]: + try: + query = """ + query SearchIssues($term: String!, $includeComments: Boolean!) { + searchIssues(term: $term, includeComments: $includeComments) { + nodes { + id + identifier + title + description + priority + } + } + } + """ + + variables: dict[str, Any] = { + "term": term, + "includeComments": True, + } + + issues = self.query(query, variables) + return [Issue(**issue) for issue in issues["searchIssues"]["nodes"]] + except LinearAPIException as e: + raise e diff --git a/autogpt_platform/backend/backend/blocks/linear/_auth.py b/autogpt_platform/backend/backend/blocks/linear/_auth.py new file mode 100644 index 000000000000..fb91fbfe7acf --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_auth.py @@ -0,0 +1,101 @@ +from enum import Enum +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + OAuth2Credentials, +) +from backend.integrations.providers import ProviderName +from backend.util.settings import Secrets + +secrets = Secrets() +LINEAR_OAUTH_IS_CONFIGURED = bool( + secrets.linear_client_id and secrets.linear_client_secret +) + +LinearCredentials = OAuth2Credentials | APIKeyCredentials +# LinearCredentialsInput = CredentialsMetaInput[ +# Literal[ProviderName.LINEAR], +# Literal["oauth2", "api_key"] if LINEAR_OAUTH_IS_CONFIGURED else Literal["oauth2"], +# ] +LinearCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.LINEAR], Literal["oauth2"] +] + + +# (required) Comma separated list of scopes: + +# read - (Default) Read access for the user's account. This scope will always be present. + +# write - Write access for the user's account. If your application only needs to create comments, use a more targeted scope + +# issues:create - Allows creating new issues and their attachments + +# comments:create - Allows creating new issue comments + +# timeSchedule:write - Allows creating and modifying time schedules + + +# admin - Full access to admin level endpoints. You should never ask for this permission unless it's absolutely needed +class LinearScope(str, Enum): + READ = "read" + WRITE = "write" + ISSUES_CREATE = "issues:create" + COMMENTS_CREATE = "comments:create" + TIME_SCHEDULE_WRITE = "timeSchedule:write" + ADMIN = "admin" + + +def LinearCredentialsField(scopes: list[LinearScope]) -> LinearCredentialsInput: + """ + Creates a Linear credentials input on a block. + + Params: + scope: The authorization scope needed for the block to work. ([list of available scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes)) + """ # noqa + return CredentialsField( + required_scopes=set([LinearScope.READ.value]).union( + set([scope.value for scope in scopes]) + ), + description="The Linear integration can be used with OAuth, " + "or any API key with sufficient permissions for the blocks it is used on.", + ) + + +TEST_CREDENTIALS_OAUTH = OAuth2Credentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="linear", + title="Mock Linear API key", + username="mock-linear-username", + access_token=SecretStr("mock-linear-access-token"), + access_token_expires_at=None, + refresh_token=SecretStr("mock-linear-refresh-token"), + refresh_token_expires_at=None, + scopes=["mock-linear-scopes"], +) + +TEST_CREDENTIALS_API_KEY = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="linear", + title="Mock Linear API key", + api_key=SecretStr("mock-linear-api-key"), + expires_at=None, +) + +TEST_CREDENTIALS_INPUT_OAUTH = { + "provider": TEST_CREDENTIALS_OAUTH.provider, + "id": TEST_CREDENTIALS_OAUTH.id, + "type": TEST_CREDENTIALS_OAUTH.type, + "title": TEST_CREDENTIALS_OAUTH.type, +} + +TEST_CREDENTIALS_INPUT_API_KEY = { + "provider": TEST_CREDENTIALS_API_KEY.provider, + "id": TEST_CREDENTIALS_API_KEY.id, + "type": TEST_CREDENTIALS_API_KEY.type, + "title": TEST_CREDENTIALS_API_KEY.type, +} diff --git a/autogpt_platform/backend/backend/blocks/linear/comment.py b/autogpt_platform/backend/backend/blocks/linear/comment.py new file mode 100644 index 000000000000..ea2ff1e61386 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/comment.py @@ -0,0 +1,81 @@ +from backend.blocks.linear._api import LinearAPIException, LinearClient +from backend.blocks.linear._auth import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearCredentials, + LinearCredentialsField, + LinearCredentialsInput, + LinearScope, +) +from backend.blocks.linear.models import CreateCommentResponse +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class LinearCreateCommentBlock(Block): + """Block for creating comments on Linear issues""" + + class Input(BlockSchema): + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.COMMENTS_CREATE], + ) + issue_id: str = SchemaField(description="ID of the issue to comment on") + comment: str = SchemaField(description="Comment text to add to the issue") + + class Output(BlockSchema): + comment_id: str = SchemaField(description="ID of the created comment") + comment_body: str = SchemaField( + description="Text content of the created comment" + ) + error: str = SchemaField(description="Error message if comment creation failed") + + def __init__(self): + super().__init__( + id="8f7d3a2e-9b5c-4c6a-8f1d-7c8b3e4a5d6c", + description="Creates a new comment on a Linear issue", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "issue_id": "TEST-123", + "comment": "Test comment", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[("comment_id", "abc123"), ("comment_body", "Test comment")], + test_mock={ + "create_comment": lambda *args, **kwargs: ( + "abc123", + "Test comment", + ) + }, + ) + + @staticmethod + def create_comment( + credentials: LinearCredentials, issue_id: str, comment: str + ) -> tuple[str, str]: + client = LinearClient(credentials=credentials) + response: CreateCommentResponse = client.try_create_comment( + issue_id=issue_id, comment=comment + ) + return response.comment.id, response.comment.body + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the comment creation""" + try: + comment_id, comment_body = self.create_comment( + credentials=credentials, + issue_id=input_data.issue_id, + comment=input_data.comment, + ) + + yield "comment_id", comment_id + yield "comment_body", comment_body + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/issues.py b/autogpt_platform/backend/backend/blocks/linear/issues.py new file mode 100644 index 000000000000..4c2c3ffa091f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/issues.py @@ -0,0 +1,186 @@ +from backend.blocks.linear._api import LinearAPIException, LinearClient +from backend.blocks.linear._auth import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearCredentials, + LinearCredentialsField, + LinearCredentialsInput, + LinearScope, +) +from backend.blocks.linear.models import CreateIssueResponse, Issue +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class LinearCreateIssueBlock(Block): + """Block for creating issues on Linear""" + + class Input(BlockSchema): + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.ISSUES_CREATE], + ) + title: str = SchemaField(description="Title of the issue") + description: str | None = SchemaField(description="Description of the issue") + team_name: str = SchemaField( + description="Name of the team to create the issue on" + ) + priority: int | None = SchemaField( + description="Priority of the issue", + default=None, + minimum=0, + maximum=4, + ) + project_name: str | None = SchemaField( + description="Name of the project to create the issue on", + default=None, + ) + + class Output(BlockSchema): + issue_id: str = SchemaField(description="ID of the created issue") + issue_title: str = SchemaField(description="Title of the created issue") + error: str = SchemaField(description="Error message if issue creation failed") + + def __init__(self): + super().__init__( + id="f9c68f55-dcca-40a8-8771-abf9601680aa", + description="Creates a new issue on Linear", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "title": "Test issue", + "description": "Test description", + "team_name": "Test team", + "project_name": "Test project", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[("issue_id", "abc123"), ("issue_title", "Test issue")], + test_mock={ + "create_issue": lambda *args, **kwargs: ( + "abc123", + "Test issue", + ) + }, + ) + + @staticmethod + def create_issue( + credentials: LinearCredentials, + team_name: str, + title: str, + description: str | None = None, + priority: int | None = None, + project_name: str | None = None, + ) -> tuple[str, str]: + client = LinearClient(credentials=credentials) + team_id = client.try_get_team_by_name(team_name=team_name) + project_id: str | None = None + if project_name: + projects = client.try_search_projects(term=project_name) + if projects: + project_id = projects[0].id + else: + raise LinearAPIException("Project not found", status_code=404) + response: CreateIssueResponse = client.try_create_issue( + team_id=team_id, + title=title, + description=description, + priority=priority, + project_id=project_id, + ) + return response.issue.identifier, response.issue.title + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the issue creation""" + try: + issue_id, issue_title = self.create_issue( + credentials=credentials, + team_name=input_data.team_name, + title=input_data.title, + description=input_data.description, + priority=input_data.priority, + project_name=input_data.project_name, + ) + + yield "issue_id", issue_id + yield "issue_title", issue_title + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" + + +class LinearSearchIssuesBlock(Block): + """Block for searching issues on Linear""" + + class Input(BlockSchema): + term: str = SchemaField(description="Term to search for issues") + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.READ], + ) + + class Output(BlockSchema): + issues: list[Issue] = SchemaField(description="List of issues") + + def __init__(self): + super().__init__( + id="b5a2a0e6-26b4-4c5b-8a42-bc79e9cb65c2", + description="Searches for issues on Linear", + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "term": "Test issue", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[ + ( + "issues", + [ + Issue( + id="abc123", + identifier="abc123", + title="Test issue", + description="Test description", + priority=1, + ) + ], + ) + ], + test_mock={ + "search_issues": lambda *args, **kwargs: [ + Issue( + id="abc123", + identifier="abc123", + title="Test issue", + description="Test description", + priority=1, + ) + ] + }, + ) + + @staticmethod + def search_issues( + credentials: LinearCredentials, + term: str, + ) -> list[Issue]: + client = LinearClient(credentials=credentials) + response: list[Issue] = client.try_search_issues(term=term) + return response + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the issue search""" + try: + issues = self.search_issues(credentials=credentials, term=input_data.term) + yield "issues", issues + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/models.py b/autogpt_platform/backend/backend/blocks/linear/models.py new file mode 100644 index 000000000000..a6a2de3cd84a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/models.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel + + +class Comment(BaseModel): + id: str + body: str + + +class CreateCommentInput(BaseModel): + body: str + issueId: str + + +class CreateCommentResponse(BaseModel): + success: bool + comment: Comment + + +class CreateCommentResponseWrapper(BaseModel): + commentCreate: CreateCommentResponse + + +class Issue(BaseModel): + id: str + identifier: str + title: str + description: str | None + priority: int + + +class CreateIssueResponse(BaseModel): + issue: Issue + + +class Project(BaseModel): + id: str + name: str + description: str + priority: int + progress: int + content: str diff --git a/autogpt_platform/backend/backend/blocks/linear/projects.py b/autogpt_platform/backend/backend/blocks/linear/projects.py new file mode 100644 index 000000000000..9d33b3f77b95 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/projects.py @@ -0,0 +1,93 @@ +from backend.blocks.linear._api import LinearAPIException, LinearClient +from backend.blocks.linear._auth import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearCredentials, + LinearCredentialsField, + LinearCredentialsInput, + LinearScope, +) +from backend.blocks.linear.models import Project +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class LinearSearchProjectsBlock(Block): + """Block for searching projects on Linear""" + + class Input(BlockSchema): + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.READ], + ) + term: str = SchemaField(description="Term to search for projects") + + class Output(BlockSchema): + projects: list[Project] = SchemaField(description="List of projects") + error: str = SchemaField(description="Error message if issue creation failed") + + def __init__(self): + super().__init__( + id="446a1d35-9d8f-4ac5-83ea-7684ec50e6af", + description="Searches for projects on Linear", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "term": "Test project", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[ + ( + "projects", + [ + Project( + id="abc123", + name="Test project", + description="Test description", + priority=1, + progress=1, + content="Test content", + ) + ], + ) + ], + test_mock={ + "search_projects": lambda *args, **kwargs: [ + Project( + id="abc123", + name="Test project", + description="Test description", + priority=1, + progress=1, + content="Test content", + ) + ] + }, + ) + + @staticmethod + def search_projects( + credentials: LinearCredentials, + term: str, + ) -> list[Project]: + client = LinearClient(credentials=credentials) + response: list[Project] = client.try_search_projects(term=term) + return response + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the project search""" + try: + projects = self.search_projects( + credentials=credentials, + term=input_data.term, + ) + + yield "projects", projects + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/triggers.py b/autogpt_platform/backend/backend/blocks/linear/triggers.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/data/block.py b/autogpt_platform/backend/backend/data/block.py index add06dbb1f7f..effdb2c5dab6 100644 --- a/autogpt_platform/backend/backend/data/block.py +++ b/autogpt_platform/backend/backend/data/block.py @@ -64,6 +64,8 @@ class BlockCategory(Enum): SAFETY = ( "Block that provides AI safety mechanisms such as detecting harmful content" ) + PRODUCTIVITY = "Block that helps with productivity" + ISSUE_TRACKING = "Block that helps with issue tracking" def dict(self) -> dict[str, str]: return {"category": self.name, "description": self.value} diff --git a/autogpt_platform/backend/backend/integrations/oauth/__init__.py b/autogpt_platform/backend/backend/integrations/oauth/__init__.py index ec45189c5952..50f8a155a6f6 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/__init__.py +++ b/autogpt_platform/backend/backend/integrations/oauth/__init__.py @@ -2,6 +2,7 @@ from .github import GitHubOAuthHandler from .google import GoogleOAuthHandler +from .linear import LinearOAuthHandler from .notion import NotionOAuthHandler from .twitter import TwitterOAuthHandler @@ -17,6 +18,7 @@ GoogleOAuthHandler, NotionOAuthHandler, TwitterOAuthHandler, + LinearOAuthHandler, ] } # --8<-- [end:HANDLERS_BY_NAMEExample] diff --git a/autogpt_platform/backend/backend/integrations/oauth/linear.py b/autogpt_platform/backend/backend/integrations/oauth/linear.py new file mode 100644 index 000000000000..fd9d379c1e0e --- /dev/null +++ b/autogpt_platform/backend/backend/integrations/oauth/linear.py @@ -0,0 +1,165 @@ +import json +from typing import Optional +from urllib.parse import urlencode + +from pydantic import SecretStr + +from backend.blocks.linear._api import LinearAPIException +from backend.data.model import APIKeyCredentials, OAuth2Credentials +from backend.integrations.providers import ProviderName +from backend.util.request import requests + +from .base import BaseOAuthHandler + + +class LinearOAuthHandler(BaseOAuthHandler): + """ + OAuth2 handler for Linear. + """ + + PROVIDER_NAME = ProviderName.LINEAR + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.auth_base_url = "https://linear.app/oauth/authorize" + self.token_url = "https://api.linear.app/oauth/token" # Correct token URL + self.revoke_url = "https://api.linear.app/oauth/revoke" + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", # Important: include "response_type" + "scope": ",".join(scopes), # Comma-separated, not space-separated + "state": state, + } + return f"{self.auth_base_url}?{urlencode(params)}" + + def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + return self._request_tokens({"code": code, "redirect_uri": self.redirect_uri}) + + def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + if not credentials.access_token: + raise ValueError("No access token to revoke") + + headers = { + "Authorization": f"Bearer {credentials.access_token.get_secret_value()}" + } + + response = requests.post(self.revoke_url, headers=headers) + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("error", "Unknown error") + except json.JSONDecodeError: + error_message = response.text + raise LinearAPIException( + f"Failed to revoke Linear tokens ({response.status_code}): {error_message}", + response.status_code, + ) + + return True # Linear doesn't return JSON on successful revoke + + def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + if not credentials.refresh_token: + raise ValueError( + "No refresh token available." + ) # Linear uses non-expiring tokens + + return self._request_tokens( + { + "refresh_token": credentials.refresh_token.get_secret_value(), + "grant_type": "refresh_token", + } + ) + + def _request_tokens( + self, + params: dict[str, str], + current_credentials: Optional[OAuth2Credentials] = None, + ) -> OAuth2Credentials: + request_body = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "authorization_code", # Ensure grant_type is correct + **params, + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } # Correct header for token request + response = requests.post(self.token_url, data=request_body, headers=headers) + + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("error", "Unknown error") + + except json.JSONDecodeError: + error_message = response.text + raise LinearAPIException( + f"Failed to fetch Linear tokens ({response.status_code}): {error_message}", + response.status_code, + ) + + token_data = response.json() + + # Note: Linear access tokens do not expire, so we set expires_at to None + new_credentials = OAuth2Credentials( + provider=self.PROVIDER_NAME, + title=current_credentials.title if current_credentials else None, + username=token_data.get("user", {}).get( + "name", "Unknown User" + ), # extract name or set appropriate + access_token=token_data["access_token"], + scopes=token_data["scope"].split( + "," + ), # Linear returns comma-separated scopes + refresh_token=token_data.get( + "refresh_token" + ), # Linear uses non-expiring tokens so this might be null + access_token_expires_at=None, + refresh_token_expires_at=None, + ) + if current_credentials: + new_credentials.id = current_credentials.id + return new_credentials + + def _request_username(self, access_token: str) -> Optional[str]: + + # Use the LinearClient to fetch user details using GraphQL + from backend.blocks.linear._api import LinearClient + + try: + + linear_client = LinearClient( + APIKeyCredentials( + api_key=SecretStr(access_token), + title="temp", + provider=self.PROVIDER_NAME, + expires_at=None, + ) + ) # Temporary credentials for this request + + query = """ + query Viewer { + viewer { + name + } + } + """ + + response = linear_client.query(query) + return response["viewer"]["name"] + + except Exception as e: # Handle any errors + + print(f"Error fetching username: {e}") + return None diff --git a/autogpt_platform/backend/backend/integrations/providers.py b/autogpt_platform/backend/backend/integrations/providers.py index d08d50e0219e..95751e92df1e 100644 --- a/autogpt_platform/backend/backend/integrations/providers.py +++ b/autogpt_platform/backend/backend/integrations/providers.py @@ -17,6 +17,7 @@ class ProviderName(str, Enum): HUBSPOT = "hubspot" IDEOGRAM = "ideogram" JINA = "jina" + LINEAR = "linear" MEDIUM = "medium" NOTION = "notion" NVIDIA = "nvidia" diff --git a/autogpt_platform/backend/backend/server/integrations/router.py b/autogpt_platform/backend/backend/server/integrations/router.py index 6a8c274dd733..b85a5513758f 100644 --- a/autogpt_platform/backend/backend/server/integrations/router.py +++ b/autogpt_platform/backend/backend/server/integrations/router.py @@ -110,6 +110,11 @@ def callback( logger.debug(f"Received credentials with final scopes: {credentials.scopes}") + # Linear returns scopes as a single string with spaces, so we need to split them + # TODO: make a bypass of this part of the OAuth handler + if len(credentials.scopes) == 1 and " " in credentials.scopes[0]: + credentials.scopes = credentials.scopes[0].split(" ") + # Check if the granted scopes are sufficient for the requested scopes if not set(scopes).issubset(set(credentials.scopes)): # For now, we'll just log the warning and continue diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index e08b5ca0bf21..8c33c14590a3 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -313,6 +313,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): e2b_api_key: str = Field(default="", description="E2B API key") nvidia_api_key: str = Field(default="", description="Nvidia API key") + linear_client_id: str = Field(default="", description="Linear client ID") + linear_client_secret: str = Field(default="", description="Linear client secret") + stripe_api_key: str = Field(default="", description="Stripe API Key") stripe_webhook_secret: str = Field(default="", description="Stripe Webhook Secret") diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx index 7a32b4378e9e..93afa31c6ee6 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx @@ -14,6 +14,7 @@ import { FaGoogle, FaMedium, FaKey, + FaHubspot, } from "react-icons/fa"; import { FC, useMemo, useState } from "react"; import { @@ -66,6 +67,7 @@ export const providerIcons: Record< google_maps: FaGoogle, jina: fallbackIcon, ideogram: fallbackIcon, + linear: fallbackIcon, medium: FaMedium, ollama: fallbackIcon, openai: fallbackIcon, @@ -79,7 +81,7 @@ export const providerIcons: Record< twitter: FaTwitter, unreal_speech: fallbackIcon, exa: fallbackIcon, - hubspot: fallbackIcon, + hubspot: FaHubspot, }; // --8<-- [end:ProviderIconsEmbed] diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx index 38c97443cebf..23a840e7946b 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx @@ -26,6 +26,7 @@ const providerDisplayNames: Record = { groq: "Groq", ideogram: "Ideogram", jina: "Jina", + linear: "Linear", medium: "Medium", notion: "Notion", nvidia: "Nvidia", diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 0eaf4fcbede2..cd839d2cd180 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -111,6 +111,7 @@ export const PROVIDER_NAMES = { GROQ: "groq", IDEOGRAM: "ideogram", JINA: "jina", + LINEAR: "linear", MEDIUM: "medium", NOTION: "notion", NVIDIA: "nvidia", From 800625c95208d609c34f8c71cbde309e151dac26 Mon Sep 17 00:00:00 2001 From: Krzysztof Czerwinski <34861343+kcze@users.noreply.github.com> Date: Sat, 18 Jan 2025 17:49:41 +0100 Subject: [PATCH 11/38] fix(frontend): Change `/store*` url to `/marketplace*` (#9119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have branded it as "Marketplace", so the URL shouldn't be "store". ### Changes 🏗️ - Change frontend URLs from `/store*` to `/marketplace*` - No API route changes to minimize bugs (follow up: https://github.com/Significant-Gravitas/AutoGPT/issues/9118) --- .../backend/backend/data/credit.py | 4 +- .../backend/backend/server/routers/v1.py | 2 +- autogpt_platform/frontend/src/app/layout.tsx | 8 +- .../(user)/api_keys/page.tsx | 0 .../(user)/credits/page.tsx | 0 .../(user)/dashboard/page.tsx | 0 .../(user)/integrations/page.tsx | 0 .../{store => marketplace}/(user)/layout.tsx | 14 +- .../(user)/profile/page.tsx | 0 .../(user)/settings/page.tsx | 0 .../agent/[creator]/[slug]/page.tsx | 4 +- .../creator/[creator]/page.tsx | 2 +- .../frontend/src/app/marketplace/page.tsx | 180 ++++++++++++++++- .../{store => marketplace}/search/page.tsx | 0 autogpt_platform/frontend/src/app/page.tsx | 2 +- .../frontend/src/app/signup/actions.ts | 2 +- .../frontend/src/app/store/page.tsx | 181 ------------------ .../src/components/agptui/AgentInfo.tsx | 2 +- .../src/components/agptui/NavbarLink.tsx | 2 +- .../src/components/agptui/SearchBar.tsx | 2 +- .../src/components/agptui/Sidebar.tsx | 24 +-- .../agptui/composite/AgentsSection.tsx | 2 +- .../agptui/composite/FeaturedCreators.tsx | 2 +- .../agptui/composite/FeaturedSection.tsx | 2 +- .../agptui/composite/HeroSection.tsx | 2 +- .../agptui/composite/PublishAgentPopout.tsx | 2 +- .../src/components/nav/NavBarButtons.tsx | 2 +- .../frontend/src/lib/supabase/middleware.ts | 8 +- .../frontend/src/tests/auth.spec.ts | 8 +- .../frontend/src/tests/profile.spec.ts | 2 +- 30 files changed, 225 insertions(+), 234 deletions(-) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/api_keys/page.tsx (100%) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/credits/page.tsx (100%) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/dashboard/page.tsx (100%) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/integrations/page.tsx (100%) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/layout.tsx (50%) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/profile/page.tsx (100%) rename autogpt_platform/frontend/src/app/{store => marketplace}/(user)/settings/page.tsx (100%) rename autogpt_platform/frontend/src/app/{store => marketplace}/agent/[creator]/[slug]/page.tsx (96%) rename autogpt_platform/frontend/src/app/{store => marketplace}/creator/[creator]/page.tsx (98%) rename autogpt_platform/frontend/src/app/{store => marketplace}/search/page.tsx (100%) delete mode 100644 autogpt_platform/frontend/src/app/store/page.tsx diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index fe76b9c21634..3dbe70cdc33d 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -310,9 +310,9 @@ async def top_up_intent(self, user_id: str, amount: int) -> str: ], mode="payment", success_url=settings.config.platform_base_url - + "/store/credits?topup=success", + + "/marketplace/credits?topup=success", cancel_url=settings.config.platform_base_url - + "/store/credits?topup=cancel", + + "/marketplace/credits?topup=cancel", ) # Create pending transaction diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index 2b1b76d651b2..263268e9095c 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -196,7 +196,7 @@ async def manage_payment_method( ) -> dict[str, str]: session = stripe.billing_portal.Session.create( customer=await get_stripe_customer_id(user_id), - return_url=settings.config.platform_base_url + "/store/credits", + return_url=settings.config.platform_base_url + "/marketplace/credits", ) if not session: raise HTTPException( diff --git a/autogpt_platform/frontend/src/app/layout.tsx b/autogpt_platform/frontend/src/app/layout.tsx index 60efbde136b2..7dafb49e08bb 100644 --- a/autogpt_platform/frontend/src/app/layout.tsx +++ b/autogpt_platform/frontend/src/app/layout.tsx @@ -49,7 +49,7 @@ export default async function RootLayout({ links={[ { name: "Marketplace", - href: "/store", + href: "/marketplace", }, { name: "Library", @@ -66,7 +66,7 @@ export default async function RootLayout({ { icon: IconType.Edit, text: "Edit profile", - href: "/store/profile", + href: "/marketplace/profile", }, ], }, @@ -75,7 +75,7 @@ export default async function RootLayout({ { icon: IconType.LayoutDashboard, text: "Creator Dashboard", - href: "/store/dashboard", + href: "/marketplace/dashboard", }, { icon: IconType.UploadCloud, @@ -88,7 +88,7 @@ export default async function RootLayout({ { icon: IconType.Settings, text: "Settings", - href: "/store/settings", + href: "/marketplace/settings", }, ], }, diff --git a/autogpt_platform/frontend/src/app/store/(user)/api_keys/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/api_keys/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/(user)/api_keys/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/api_keys/page.tsx diff --git a/autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/credits/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/(user)/credits/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/credits/page.tsx diff --git a/autogpt_platform/frontend/src/app/store/(user)/dashboard/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/dashboard/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/(user)/dashboard/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/dashboard/page.tsx diff --git a/autogpt_platform/frontend/src/app/store/(user)/integrations/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/integrations/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/(user)/integrations/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/integrations/page.tsx diff --git a/autogpt_platform/frontend/src/app/store/(user)/layout.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/layout.tsx similarity index 50% rename from autogpt_platform/frontend/src/app/store/(user)/layout.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/layout.tsx index fa6a66fe95e1..07b7d3f4b444 100644 --- a/autogpt_platform/frontend/src/app/store/(user)/layout.tsx +++ b/autogpt_platform/frontend/src/app/marketplace/(user)/layout.tsx @@ -5,13 +5,13 @@ 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: "Credits", href: "/store/credits" }, - { text: "Integrations", href: "/store/integrations" }, - { text: "API Keys", href: "/store/api_keys" }, - { text: "Profile", href: "/store/profile" }, - { text: "Settings", href: "/store/settings" }, + { text: "Creator Dashboard", href: "/marketplace/dashboard" }, + { text: "Agent dashboard", href: "/marketplace/agent-dashboard" }, + { text: "Credits", href: "/marketplace/credits" }, + { text: "Integrations", href: "/marketplace/integrations" }, + { text: "API Keys", href: "/marketplace/api_keys" }, + { text: "Profile", href: "/marketplace/profile" }, + { text: "Settings", href: "/marketplace/settings" }, ], }, ]; diff --git a/autogpt_platform/frontend/src/app/store/(user)/profile/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/profile/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/(user)/profile/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/profile/page.tsx diff --git a/autogpt_platform/frontend/src/app/store/(user)/settings/page.tsx b/autogpt_platform/frontend/src/app/marketplace/(user)/settings/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/(user)/settings/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/(user)/settings/page.tsx diff --git a/autogpt_platform/frontend/src/app/store/agent/[creator]/[slug]/page.tsx b/autogpt_platform/frontend/src/app/marketplace/agent/[creator]/[slug]/page.tsx similarity index 96% rename from autogpt_platform/frontend/src/app/store/agent/[creator]/[slug]/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/agent/[creator]/[slug]/page.tsx index b5d0ebeaa242..cd6b44439644 100644 --- a/autogpt_platform/frontend/src/app/store/agent/[creator]/[slug]/page.tsx +++ b/autogpt_platform/frontend/src/app/marketplace/agent/[creator]/[slug]/page.tsx @@ -45,10 +45,10 @@ export default async function Page({ }); const breadcrumbs = [ - { name: "Store", link: "/store" }, + { name: "Store", link: "/marketplace" }, { name: agent.creator, - link: `/store/creator/${encodeURIComponent(agent.creator)}`, + link: `/marketplace/creator/${encodeURIComponent(agent.creator)}`, }, { name: agent.agent_name, link: "#" }, ]; diff --git a/autogpt_platform/frontend/src/app/store/creator/[creator]/page.tsx b/autogpt_platform/frontend/src/app/marketplace/creator/[creator]/page.tsx similarity index 98% rename from autogpt_platform/frontend/src/app/store/creator/[creator]/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/creator/[creator]/page.tsx index 7474aef2e7a3..904f02b93f40 100644 --- a/autogpt_platform/frontend/src/app/store/creator/[creator]/page.tsx +++ b/autogpt_platform/frontend/src/app/marketplace/creator/[creator]/page.tsx @@ -47,7 +47,7 @@ export default async function Page({
diff --git a/autogpt_platform/frontend/src/app/marketplace/page.tsx b/autogpt_platform/frontend/src/app/marketplace/page.tsx index b59d651f0b42..297fee0c7fca 100644 --- a/autogpt_platform/frontend/src/app/marketplace/page.tsx +++ b/autogpt_platform/frontend/src/app/marketplace/page.tsx @@ -1,7 +1,179 @@ -"use client"; +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 { Metadata } from "next"; +import { + StoreAgentsResponse, + CreatorsResponse, +} from "@/lib/autogpt-server-api/types"; +import BackendAPI from "@/lib/autogpt-server-api"; -import { redirect } from "next/navigation"; +async function getStoreData() { + try { + const api = new BackendAPI(); -export default function Page() { - redirect("/store"); + // Add error handling and default values + let featuredAgents: StoreAgentsResponse = { + agents: [], + pagination: { + total_items: 0, + total_pages: 0, + current_page: 0, + page_size: 0, + }, + }; + let topAgents: StoreAgentsResponse = { + agents: [], + pagination: { + total_items: 0, + total_pages: 0, + current_page: 0, + page_size: 0, + }, + }; + let featuredCreators: CreatorsResponse = { + creators: [], + pagination: { + total_items: 0, + total_pages: 0, + current_page: 0, + page_size: 0, + }, + }; + + try { + [featuredAgents, topAgents, featuredCreators] = await Promise.all([ + api.getStoreAgents({ featured: true }), + api.getStoreAgents({ sorted_by: "runs" }), + api.getStoreCreators({ featured: true, sorted_by: "num_agents" }), + ]); + } catch (error) { + console.error("Error fetching store data:", error); + } + + return { + featuredAgents, + topAgents, + featuredCreators, + }; + } catch (error) { + console.error("Error in getStoreData:", error); + return { + featuredAgents: { + agents: [], + pagination: { + total_items: 0, + total_pages: 0, + current_page: 0, + page_size: 0, + }, + }, + topAgents: { + agents: [], + pagination: { + total_items: 0, + total_pages: 0, + current_page: 0, + page_size: 0, + }, + }, + featuredCreators: { + creators: [], + pagination: { + total_items: 0, + total_pages: 0, + current_page: 0, + page_size: 0, + }, + }, + }; + } +} + +// FIX: Correct metadata +export const metadata: Metadata = { + title: "Marketplace - 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: "Marketplace - 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: "Marketplace - 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({}: {}) { + // Get data server-side + const { featuredAgents, topAgents, featuredCreators } = await getStoreData(); + + return ( +
+
+ + + + + + + + +
+
+ ); } diff --git a/autogpt_platform/frontend/src/app/store/search/page.tsx b/autogpt_platform/frontend/src/app/marketplace/search/page.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/store/search/page.tsx rename to autogpt_platform/frontend/src/app/marketplace/search/page.tsx diff --git a/autogpt_platform/frontend/src/app/page.tsx b/autogpt_platform/frontend/src/app/page.tsx index b59d651f0b42..5a079f262994 100644 --- a/autogpt_platform/frontend/src/app/page.tsx +++ b/autogpt_platform/frontend/src/app/page.tsx @@ -3,5 +3,5 @@ import { redirect } from "next/navigation"; export default function Page() { - redirect("/store"); + redirect("/marketplace"); } diff --git a/autogpt_platform/frontend/src/app/signup/actions.ts b/autogpt_platform/frontend/src/app/signup/actions.ts index 0d0c3fb8a45a..80fd19aa90b8 100644 --- a/autogpt_platform/frontend/src/app/signup/actions.ts +++ b/autogpt_platform/frontend/src/app/signup/actions.ts @@ -38,7 +38,7 @@ export async function signup(values: z.infer) { } console.log("Signed up"); revalidatePath("/", "layout"); - redirect("/store/profile"); + redirect("/marketplace/profile"); }, ); } diff --git a/autogpt_platform/frontend/src/app/store/page.tsx b/autogpt_platform/frontend/src/app/store/page.tsx deleted file mode 100644 index c59a12474125..000000000000 --- a/autogpt_platform/frontend/src/app/store/page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -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 { Metadata } from "next"; -import { - StoreAgentsResponse, - CreatorsResponse, -} from "@/lib/autogpt-server-api/types"; -import BackendAPI from "@/lib/autogpt-server-api"; - -export const dynamic = "force-dynamic"; - -async function getStoreData() { - try { - const api = new BackendAPI(); - - // Add error handling and default values - let featuredAgents: StoreAgentsResponse = { - agents: [], - pagination: { - total_items: 0, - total_pages: 0, - current_page: 0, - page_size: 0, - }, - }; - let topAgents: StoreAgentsResponse = { - agents: [], - pagination: { - total_items: 0, - total_pages: 0, - current_page: 0, - page_size: 0, - }, - }; - let featuredCreators: CreatorsResponse = { - creators: [], - pagination: { - total_items: 0, - total_pages: 0, - current_page: 0, - page_size: 0, - }, - }; - - try { - [featuredAgents, topAgents, featuredCreators] = await Promise.all([ - api.getStoreAgents({ featured: true }), - api.getStoreAgents({ sorted_by: "runs" }), - api.getStoreCreators({ featured: true, sorted_by: "num_agents" }), - ]); - } catch (error) { - console.error("Error fetching store data:", error); - } - - return { - featuredAgents, - topAgents, - featuredCreators, - }; - } catch (error) { - console.error("Error in getStoreData:", error); - return { - featuredAgents: { - agents: [], - pagination: { - total_items: 0, - total_pages: 0, - current_page: 0, - page_size: 0, - }, - }, - topAgents: { - agents: [], - pagination: { - total_items: 0, - total_pages: 0, - current_page: 0, - page_size: 0, - }, - }, - featuredCreators: { - creators: [], - pagination: { - total_items: 0, - total_pages: 0, - current_page: 0, - page_size: 0, - }, - }, - }; - } -} - -// FIX: Correct metadata -export const metadata: Metadata = { - title: "Marketplace - 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: "Marketplace - 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: "Marketplace - 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({}: {}) { - // Get data server-side - const { featuredAgents, topAgents, featuredCreators } = await getStoreData(); - - return ( -
-
- - - - - - - - -
-
- ); -} diff --git a/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx b/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx index 03f7d141d7a8..7291b7923e80 100644 --- a/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx +++ b/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx @@ -105,7 +105,7 @@ export const AgentInfo: React.FC = ({ by
{creator} diff --git a/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx b/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx index 8d4094b40c8f..5bfe77d86dd8 100644 --- a/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx +++ b/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx @@ -28,7 +28,7 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => { : "" } flex items-center justify-start gap-3`} > - {href === "/store" && ( + {href === "/marketplace" && ( diff --git a/autogpt_platform/frontend/src/components/agptui/SearchBar.tsx b/autogpt_platform/frontend/src/components/agptui/SearchBar.tsx index 95e3a3fe87a6..7f23d87f0777 100644 --- a/autogpt_platform/frontend/src/components/agptui/SearchBar.tsx +++ b/autogpt_platform/frontend/src/components/agptui/SearchBar.tsx @@ -36,7 +36,7 @@ export const SearchBar: React.FC = ({ if (searchQuery.trim()) { // Encode the search term and navigate to the desired path const encodedTerm = encodeURIComponent(searchQuery); - router.push(`/store/search?searchTerm=${encodedTerm}`); + router.push(`/marketplace/search?searchTerm=${encodedTerm}`); } }; diff --git a/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx b/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx index 09267bed7f94..08f56faa1fb5 100644 --- a/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx +++ b/autogpt_platform/frontend/src/components/agptui/Sidebar.tsx @@ -46,7 +46,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -56,7 +56,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => { {stripeAvailable && ( @@ -66,7 +66,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => { )} @@ -75,7 +75,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -84,7 +84,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -93,7 +93,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -110,7 +110,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -120,7 +120,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => { {stripeAvailable && ( @@ -130,7 +130,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => { )} @@ -139,7 +139,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -148,7 +148,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => {
@@ -157,7 +157,7 @@ export const Sidebar: React.FC = ({ linkGroups }) => { diff --git a/autogpt_platform/frontend/src/components/agptui/composite/AgentsSection.tsx b/autogpt_platform/frontend/src/components/agptui/composite/AgentsSection.tsx index fc4e13af4f53..91cbfb1b016e 100644 --- a/autogpt_platform/frontend/src/components/agptui/composite/AgentsSection.tsx +++ b/autogpt_platform/frontend/src/components/agptui/composite/AgentsSection.tsx @@ -39,7 +39,7 @@ export const AgentsSection: React.FC = ({ const handleCardClick = (creator: string, slug: string) => { router.push( - `/store/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`, + `/marketplace/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`, ); }; diff --git a/autogpt_platform/frontend/src/components/agptui/composite/FeaturedCreators.tsx b/autogpt_platform/frontend/src/components/agptui/composite/FeaturedCreators.tsx index bb3ccbec699d..bca4c1fc859f 100644 --- a/autogpt_platform/frontend/src/components/agptui/composite/FeaturedCreators.tsx +++ b/autogpt_platform/frontend/src/components/agptui/composite/FeaturedCreators.tsx @@ -24,7 +24,7 @@ export const FeaturedCreators: React.FC = ({ const router = useRouter(); const handleCardClick = (creator: string) => { - router.push(`/store/creator/${encodeURIComponent(creator)}`); + router.push(`/marketplace/creator/${encodeURIComponent(creator)}`); }; // Only show first 4 creators diff --git a/autogpt_platform/frontend/src/components/agptui/composite/FeaturedSection.tsx b/autogpt_platform/frontend/src/components/agptui/composite/FeaturedSection.tsx index c8d915521252..5c1d257d922c 100644 --- a/autogpt_platform/frontend/src/components/agptui/composite/FeaturedSection.tsx +++ b/autogpt_platform/frontend/src/components/agptui/composite/FeaturedSection.tsx @@ -43,7 +43,7 @@ export const FeaturedSection: React.FC = ({ const handleCardClick = (creator: string, slug: string) => { router.push( - `/store/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`, + `/marketplace/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`, ); }; diff --git a/autogpt_platform/frontend/src/components/agptui/composite/HeroSection.tsx b/autogpt_platform/frontend/src/components/agptui/composite/HeroSection.tsx index e7b19e1ca7ec..61d025c35158 100644 --- a/autogpt_platform/frontend/src/components/agptui/composite/HeroSection.tsx +++ b/autogpt_platform/frontend/src/components/agptui/composite/HeroSection.tsx @@ -10,7 +10,7 @@ export const HeroSection: React.FC = () => { function onFilterChange(selectedFilters: string[]) { const encodedTerm = encodeURIComponent(selectedFilters.join(", ")); - router.push(`/store/search?searchTerm=${encodedTerm}`); + router.push(`/marketplace/search?searchTerm=${encodedTerm}`); } return ( diff --git a/autogpt_platform/frontend/src/components/agptui/composite/PublishAgentPopout.tsx b/autogpt_platform/frontend/src/components/agptui/composite/PublishAgentPopout.tsx index f2b3c9b79c73..56cdac79e9ce 100644 --- a/autogpt_platform/frontend/src/components/agptui/composite/PublishAgentPopout.tsx +++ b/autogpt_platform/frontend/src/components/agptui/composite/PublishAgentPopout.tsx @@ -260,7 +260,7 @@ export const PublishAgentPopout: React.FC = ({ onClose={handleClose} onDone={handleClose} onViewProgress={() => { - router.push("/store/dashboard"); + router.push("/marketplace/dashboard"); handleClose(); }} /> diff --git a/autogpt_platform/frontend/src/components/nav/NavBarButtons.tsx b/autogpt_platform/frontend/src/components/nav/NavBarButtons.tsx index 5e81f17bd0a4..7852fb941e0b 100644 --- a/autogpt_platform/frontend/src/components/nav/NavBarButtons.tsx +++ b/autogpt_platform/frontend/src/components/nav/NavBarButtons.tsx @@ -24,7 +24,7 @@ export function NavBarButtons({ className }: { className?: string }) { icon: , }, { - href: "/store", + href: "/marketplace", text: "Marketplace", icon: , }, diff --git a/autogpt_platform/frontend/src/lib/supabase/middleware.ts b/autogpt_platform/frontend/src/lib/supabase/middleware.ts index e6b4308a2bd4..363d9da1afd1 100644 --- a/autogpt_platform/frontend/src/lib/supabase/middleware.ts +++ b/autogpt_platform/frontend/src/lib/supabase/middleware.ts @@ -5,9 +5,9 @@ import { NextResponse, type NextRequest } from "next/server"; const PROTECTED_PAGES = [ "/monitor", "/build", - "/store/profile", - "/store/settings", - "/store/dashboard", + "/marketplace/profile", + "/marketplace/settings", + "/marketplace/dashboard", ]; const ADMIN_PAGES = ["/admin"]; @@ -87,7 +87,7 @@ export async function updateSession(request: NextRequest) { ADMIN_PAGES.some((page) => request.nextUrl.pathname.startsWith(`${page}`)) ) { // no user, potentially respond by redirecting the user to the login page - url.pathname = `/store`; + url.pathname = `/marketplace`; return NextResponse.redirect(url); } diff --git a/autogpt_platform/frontend/src/tests/auth.spec.ts b/autogpt_platform/frontend/src/tests/auth.spec.ts index 8c7ac4ab7780..a658dbdb4138 100644 --- a/autogpt_platform/frontend/src/tests/auth.spec.ts +++ b/autogpt_platform/frontend/src/tests/auth.spec.ts @@ -5,7 +5,7 @@ test.describe("Authentication", () => { test("user can login successfully", async ({ page, loginPage, testUser }) => { await page.goto("/login"); await loginPage.login(testUser.email, testUser.password); - await test.expect(page).toHaveURL("/store"); + await test.expect(page).toHaveURL("/marketplace"); await test .expect(page.getByTestId("profile-popout-menu-trigger")) .toBeVisible(); @@ -19,7 +19,7 @@ test.describe("Authentication", () => { await page.goto("/login"); await loginPage.login(testUser.email, testUser.password); - await test.expect(page).toHaveURL("/store"); + await test.expect(page).toHaveURL("/marketplace"); // Click on the profile menu trigger to open popout await page.getByTestId("profile-popout-menu-trigger").click(); @@ -43,7 +43,7 @@ test.describe("Authentication", () => { }) => { await page.goto("/login"); await loginPage.login(testUser.email, testUser.password); - await test.expect(page).toHaveURL("/store"); + await test.expect(page).toHaveURL("/marketplace"); // Click on the profile menu trigger to open popout await page.getByTestId("profile-popout-menu-trigger").click(); @@ -52,7 +52,7 @@ test.describe("Authentication", () => { await test.expect(page).toHaveURL("/login"); await loginPage.login(testUser.email, testUser.password); - await test.expect(page).toHaveURL("/store"); + await test.expect(page).toHaveURL("/marketplace"); await test .expect(page.getByTestId("profile-popout-menu-trigger")) .toBeVisible(); diff --git a/autogpt_platform/frontend/src/tests/profile.spec.ts b/autogpt_platform/frontend/src/tests/profile.spec.ts index 03787e2748fb..22048b0caa2c 100644 --- a/autogpt_platform/frontend/src/tests/profile.spec.ts +++ b/autogpt_platform/frontend/src/tests/profile.spec.ts @@ -10,7 +10,7 @@ test.describe("Profile", () => { // Start each test with login using worker auth await page.goto("/login"); await loginPage.login(testUser.email, testUser.password); - await test.expect(page).toHaveURL("/store"); + await test.expect(page).toHaveURL("/marketplace"); }); test("user can view their profile information", async ({ From 5383e8ba27e7ed9a1e55cd2023d548bdb96a1700 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 03:17:48 -0600 Subject: [PATCH 12/38] chore(libs/deps-dev): bump ruff from 0.8.6 to 0.9.2 in /autogpt_platform/autogpt_libs in the development-dependencies group across 1 directory (#9299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the development-dependencies group with 1 update in the /autogpt_platform/autogpt_libs directory: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.8.6 to 0.9.2
Changelog

Sourced from ruff's changelog.

0.9.2

Preview features

  • [airflow] Fix typo "security_managr" to "security_manager" (AIR303) (#15463)
  • [airflow] extend and fix AIR302 rules (#15525)
  • [fastapi] Handle parameters with Depends correctly (FAST003) (#15364)
  • [flake8-pytest-style] Implement pytest.warns diagnostics (PT029, PT030, PT031) (#15444)
  • [flake8-pytest-style] Test function parameters with default arguments (PT028) (#15449)
  • [flake8-type-checking] Avoid false positives for | in TC008 (#15201)

Rule changes

  • [flake8-todos] Allow VSCode GitHub PR extension style links in missing-todo-link (TD003) (#15519)
  • [pyflakes] Show syntax error message for F722 (#15523)

Formatter

  • Fix curly bracket spacing around f-string expressions containing curly braces (#15471)
  • Fix joining of f-strings with different quotes when using quote style Preserve (#15524)

Server

  • Avoid indexing the same workspace multiple times (#15495)
  • Display context for ruff.configuration errors (#15452)

Configuration

  • Remove flatten to improve deserialization error messages (#15414)

Bug fixes

  • Parse triple-quoted string annotations as if parenthesized (#15387)
  • [fastapi] Update Annotated fixes (FAST002) (#15462)
  • [flake8-bandit] Check for builtins instead of builtin (S102, PTH123) (#15443)
  • [flake8-pathlib] Fix --select for os-path-dirname (PTH120) (#15446)
  • [ruff] Fix false positive on global keyword (RUF052) (#15235)

0.9.1

Preview features

  • [pycodestyle] Run too-many-newlines-at-end-of-file on each cell in notebooks (W391) (#15308)
  • [ruff] Omit diagnostic for shadowed private function parameters in used-dummy-variable (RUF052) (#15376)

Rule changes

  • [flake8-bugbear] Improve assert-raises-exception message (B017) (#15389)

Formatter

... (truncated)

Commits
  • 0a39348 Include build binaries
  • 027f800 Comment out non-npm-publish jobs
  • 425870d Upload npm publish logs when failed
  • c20255a Bump version to 0.9.2 (#15529)
  • 4203658 Fix joining of f-strings with different quotes when using quote style `Preser...
  • fc9dd63 [airflow] extend and fix AIR302 rules (#15525)
  • 79e52c7 [pyflakes] Show syntax error message for F722 (#15523)
  • cf4ab7c Parse triple quoted string annotations as if parenthesized (#15387)
  • d2656e8 [flake8-todos] Allow VSCode GitHub PR extension style links in `missing-tod...
  • c53ee60 Typeshed-sync workflow: add appropriate labels, link directly to failing run ...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ruff&package-manager=pip&previous-version=0.8.6&new-version=0.9.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- autogpt_platform/autogpt_libs/poetry.lock | 40 ++++++++++---------- autogpt_platform/autogpt_libs/pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/autogpt_platform/autogpt_libs/poetry.lock b/autogpt_platform/autogpt_libs/poetry.lock index e194da4ca2f4..7a56c3f59ce0 100644 --- a/autogpt_platform/autogpt_libs/poetry.lock +++ b/autogpt_platform/autogpt_libs/poetry.lock @@ -1415,29 +1415,29 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.8.6" +version = "0.9.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"}, - {file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"}, - {file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"}, - {file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"}, - {file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"}, - {file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"}, - {file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"}, - {file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"}, - {file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"}, - {file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"}, - {file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"}, - {file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"}, + {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, + {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, + {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, + {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, + {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, + {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, + {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, ] [[package]] @@ -1852,4 +1852,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "bf1b0125759dadb1369fff05ffba64fea3e82b9b7a43d0068e1c80974a4ebc1c" +content-hash = "c4d1013490d59a8953ec233eda4c2f3b1ac458a358e1ff4cb4ff508b1a967018" diff --git a/autogpt_platform/autogpt_libs/pyproject.toml b/autogpt_platform/autogpt_libs/pyproject.toml index 49746c56e4ce..c7c75cf85e63 100644 --- a/autogpt_platform/autogpt_libs/pyproject.toml +++ b/autogpt_platform/autogpt_libs/pyproject.toml @@ -21,7 +21,7 @@ supabase = "^2.10.0" [tool.poetry.group.dev.dependencies] redis = "^5.2.1" -ruff = "^0.8.6" +ruff = "^0.9.2" [build-system] requires = ["poetry-core"] From da7aead3617e82f05fbf3e131f22c26e879a8284 Mon Sep 17 00:00:00 2001 From: Reinier van der Leer Date: Fri, 24 Jan 2025 14:17:46 +0100 Subject: [PATCH 13/38] fix(frontend): Fix page layouts (sizing + padding) (#9311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolves #9310 ### Changes 🏗️ - Make base layout full width and fix its sizing behavior - Fix navbar overflowing on the right - Set padding on `/monitoring` - Make `/login` and `/signup` layouts self-center ### Checklist 📋 - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - Check layouts of all pages - `/signup` - `/login` - `/build` - `/monitoring` - `/store` - `/store/profile` - `/store/dashboard` - `/store/settings` --------- Co-authored-by: Zamil Majdy --- autogpt_platform/frontend/src/app/layout.tsx | 4 ++-- autogpt_platform/frontend/src/app/login/page.tsx | 2 +- autogpt_platform/frontend/src/app/monitoring/page.tsx | 2 +- autogpt_platform/frontend/src/app/signup/page.tsx | 2 +- .../frontend/src/components/agptui/Navbar.tsx | 2 +- .../frontend/src/components/auth/AuthCard.tsx | 11 +++++++++-- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/autogpt_platform/frontend/src/app/layout.tsx b/autogpt_platform/frontend/src/app/layout.tsx index 7dafb49e08bb..9f05958370bf 100644 --- a/autogpt_platform/frontend/src/app/layout.tsx +++ b/autogpt_platform/frontend/src/app/layout.tsx @@ -44,7 +44,7 @@ export default async function RootLayout({ // enableSystem disableTransitionOnChange > -
+
-
{children}
+
{children}
diff --git a/autogpt_platform/frontend/src/app/login/page.tsx b/autogpt_platform/frontend/src/app/login/page.tsx index 6edaf9900641..bb7cf78fe2b2 100644 --- a/autogpt_platform/frontend/src/app/login/page.tsx +++ b/autogpt_platform/frontend/src/app/login/page.tsx @@ -93,7 +93,7 @@ export default function LoginPage() { } return ( - + Login to your account
diff --git a/autogpt_platform/frontend/src/app/monitoring/page.tsx b/autogpt_platform/frontend/src/app/monitoring/page.tsx index 66a37c96cfc4..0be12aaee5c5 100644 --- a/autogpt_platform/frontend/src/app/monitoring/page.tsx +++ b/autogpt_platform/frontend/src/app/monitoring/page.tsx @@ -73,7 +73,7 @@ const Monitor = () => { return (
+ Create a new account diff --git a/autogpt_platform/frontend/src/components/agptui/Navbar.tsx b/autogpt_platform/frontend/src/components/agptui/Navbar.tsx index 33d3fbf42acd..9c3c260b9462 100644 --- a/autogpt_platform/frontend/src/components/agptui/Navbar.tsx +++ b/autogpt_platform/frontend/src/components/agptui/Navbar.tsx @@ -57,7 +57,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => { return ( <> -
Release notes

Sourced from ruff's releases.

0.9.2

Release Notes

Preview features

  • [airflow] Fix typo "security_managr" to "security_manager" (AIR303) (#15463)
  • [airflow] extend and fix AIR302 rules (#15525)
  • [fastapi] Handle parameters with Depends correctly (FAST003) (#15364)
  • [flake8-pytest-style] Implement pytest.warns diagnostics (PT029, PT030, PT031) (#15444)
  • [flake8-pytest-style] Test function parameters with default arguments (PT028) (#15449)
  • [flake8-type-checking] Avoid false positives for | in TC008 (#15201)

Rule changes

  • [flake8-todos] Allow VSCode GitHub PR extension style links in missing-todo-link (TD003) (#15519)
  • [pyflakes] Show syntax error message for F722 (#15523)

Formatter

  • Fix curly bracket spacing around f-string expressions containing curly braces (#15471)
  • Fix joining of f-strings with different quotes when using quote style Preserve (#15524)

Server

  • Avoid indexing the same workspace multiple times (#15495)
  • Display context for ruff.configuration errors (#15452)

Configuration

  • Remove flatten to improve deserialization error messages (#15414)

Bug fixes

  • Parse triple-quoted string annotations as if parenthesized (#15387)
  • [fastapi] Update Annotated fixes (FAST002) (#15462)
  • [flake8-bandit] Check for builtins instead of builtin (S102, PTH123) (#15443)
  • [flake8-pathlib] Fix --select for os-path-dirname (PTH120) (#15446)
  • [ruff] Fix false positive on global keyword (RUF052) (#15235)

Contributors

... (truncated)