diff --git a/packages/frontend-2/components/viewer/gendo/Item.vue b/packages/frontend-2/components/viewer/gendo/Item.vue index cbbc0902c..942b45139 100644 --- a/packages/frontend-2/components/viewer/gendo/Item.vue +++ b/packages/frontend-2/components/viewer/gendo/Item.vue @@ -60,7 +60,7 @@ import { ArrowDownTrayIcon } from '@heroicons/vue/24/outline' import { useCameraUtilities } from '~/lib/viewer/composables/ui' -import type { Vector3 } from 'three' +import { Vector3 } from 'three' const props = defineProps<{ renderRequest: GendoAiRender @@ -108,10 +108,11 @@ const renderUrl = computed(() => { const setView = () => { const cam = detailedRender.value?.camera as { target: Vector3; position: Vector3 } + setViewInternal( { - target: cam.target, - position: cam.position + target: new Vector3(cam.target.x, cam.target.y, cam.target.z), + position: new Vector3(cam.position.x, cam.position.y, cam.position.z) }, true ) diff --git a/packages/frontend-2/components/viewer/gendo/Panel.vue b/packages/frontend-2/components/viewer/gendo/Panel.vue index e33344792..2780db69e 100644 --- a/packages/frontend-2/components/viewer/gendo/Panel.vue +++ b/packages/frontend-2/components/viewer/gendo/Panel.vue @@ -11,6 +11,7 @@ > Gendo +  (Beta)
@@ -23,38 +24,67 @@ placeholder="Your prompt" />
+
+ You have used {{ limits.used }} out of {{ limits.limit }} monthly free + renders. +
Render + + Visit Gendo +
diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index b496cd05f..b5a2ec3c6 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -186,6 +186,7 @@ const documents = { "\n query DeveloperSettingsAuthorizedApps {\n activeUser {\n id\n authorizedApps {\n id\n description\n name\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": types.DeveloperSettingsAuthorizedAppsDocument, "\n query SearchProjects($search: String, $onlyWithRoles: [String!] = null) {\n activeUser {\n projects(limit: 10, filter: { search: $search, onlyWithRoles: $onlyWithRoles }) {\n totalCount\n items {\n ...FormSelectProjects_Project\n }\n }\n }\n }\n": types.SearchProjectsDocument, "\n query SearchProjectModels($search: String, $projectId: String!) {\n project(id: $projectId) {\n id\n models(limit: 10, filter: { search: $search }) {\n totalCount\n items {\n ...FormSelectModels_Model\n }\n }\n }\n }\n": types.SearchProjectModelsDocument, + "\n query ActiveUserGendoLimits {\n activeUser {\n id\n gendoAICredits {\n used\n limit\n resetDate\n }\n }\n }\n": types.ActiveUserGendoLimitsDocument, "\n mutation requestGendoAIRender($input: GendoAIRenderInput!) {\n versionMutations {\n requestGendoAIRender(input: $input)\n }\n }\n": types.RequestGendoAiRenderDocument, "\n query GendoAIRender(\n $gendoAiRenderId: String!\n $versionId: String!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRender(id: $gendoAiRenderId) {\n id\n projectId\n modelId\n versionId\n createdAt\n updatedAt\n gendoGenerationId\n status\n prompt\n camera\n responseImage\n user {\n name\n avatar\n id\n }\n }\n }\n }\n }\n": types.GendoAiRenderDocument, "\n query GendoAIRenders($versionId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n version(id: $versionId) {\n id\n gendoAIRenders {\n totalCount\n items {\n id\n createdAt\n updatedAt\n status\n gendoGenerationId\n prompt\n camera\n }\n }\n }\n }\n }\n": types.GendoAiRendersDocument, @@ -1059,6 +1060,10 @@ export function graphql(source: "\n query SearchProjects($search: String, $only * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query SearchProjectModels($search: String, $projectId: String!) {\n project(id: $projectId) {\n id\n models(limit: 10, filter: { search: $search }) {\n totalCount\n items {\n ...FormSelectModels_Model\n }\n }\n }\n }\n"): (typeof documents)["\n query SearchProjectModels($search: String, $projectId: String!) {\n project(id: $projectId) {\n id\n models(limit: 10, filter: { search: $search }) {\n totalCount\n items {\n ...FormSelectModels_Model\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query ActiveUserGendoLimits {\n activeUser {\n id\n gendoAICredits {\n used\n limit\n resetDate\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserGendoLimits {\n activeUser {\n id\n gendoAICredits {\n used\n limit\n resetDate\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 4d421cf8d..5a632111b 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -3555,6 +3555,7 @@ export type User = { * @deprecated Part of the old API surface and will be removed in the future. */ favoriteStreams: StreamCollection; + gendoAICredits: UserGendoAiCredits; /** Whether the user has a pending/active email verification token */ hasPendingVerification?: Maybe; id: Scalars['ID']['output']; @@ -3743,6 +3744,13 @@ export type UserEmailMutationsSetPrimaryArgs = { input: SetPrimaryUserEmailInput; }; +export type UserGendoAiCredits = { + __typename?: 'UserGendoAICredits'; + limit: Scalars['Int']['output']; + resetDate: Scalars['DateTime']['output']; + used: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -4949,6 +4957,11 @@ export type SearchProjectModelsQueryVariables = Exact<{ export type SearchProjectModelsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, models: { __typename?: 'ModelCollection', totalCount: number, items: Array<{ __typename?: 'Model', id: string, name: string }> } } }; +export type ActiveUserGendoLimitsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ActiveUserGendoLimitsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, gendoAICredits: { __typename?: 'UserGendoAICredits', used: number, limit: number, resetDate: string } } | null }; + export type RequestGendoAiRenderMutationVariables = Exact<{ input: GendoAiRenderInput; }>; @@ -6257,6 +6270,7 @@ export const DeveloperSettingsApplicationsDocument = {"kind":"Document","definit export const DeveloperSettingsAuthorizedAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DeveloperSettingsAuthorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const SearchProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"onlyWithRoles"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"onlyWithRoles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"onlyWithRoles"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormSelectProjects_Project"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormSelectProjects_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; export const SearchProjectModelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchProjectModels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormSelectModels_Model"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormSelectModels_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode; +export const ActiveUserGendoLimitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserGendoLimits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"gendoAICredits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"used"}},{"kind":"Field","name":{"kind":"Name","value":"limit"}},{"kind":"Field","name":{"kind":"Name","value":"resetDate"}}]}}]}}]}}]} as unknown as DocumentNode; export const RequestGendoAiRenderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"requestGendoAIRender"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GendoAIRenderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestGendoAIRender"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode; export const GendoAiRenderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GendoAIRender"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"gendoAiRenderId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"version"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"gendoAIRender"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"gendoAiRenderId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"versionId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"gendoGenerationId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"camera"}},{"kind":"Field","name":{"kind":"Name","value":"responseImage"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GendoAiRendersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GendoAIRenders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"version"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"gendoAIRenders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"gendoGenerationId"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"camera"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; @@ -6512,6 +6526,7 @@ export type AllObjectTypes = { UserAutomateInfo: UserAutomateInfo, UserEmail: UserEmail, UserEmailMutations: UserEmailMutations, + UserGendoAICredits: UserGendoAiCredits, UserProjectsUpdatedMessage: UserProjectsUpdatedMessage, UserSearchResultCollection: UserSearchResultCollection, Version: Version, @@ -7488,6 +7503,7 @@ export type UserFieldArgs = { emails: {}, expiredSsoSessions: {}, favoriteStreams: UserFavoriteStreamsArgs, + gendoAICredits: {}, hasPendingVerification: {}, id: {}, isOnboardingFinished: {}, @@ -7523,6 +7539,11 @@ export type UserEmailMutationsFieldArgs = { requestNewEmailVerification: UserEmailMutationsRequestNewEmailVerificationArgs, setPrimary: UserEmailMutationsSetPrimaryArgs, } +export type UserGendoAiCreditsFieldArgs = { + limit: {}, + resetDate: {}, + used: {}, +} export type UserProjectsUpdatedMessageFieldArgs = { id: {}, project: {}, @@ -7829,6 +7850,7 @@ export type AllObjectFieldArgTypes = { UserAutomateInfo: UserAutomateInfoFieldArgs, UserEmail: UserEmailFieldArgs, UserEmailMutations: UserEmailMutationsFieldArgs, + UserGendoAICredits: UserGendoAiCreditsFieldArgs, UserProjectsUpdatedMessage: UserProjectsUpdatedMessageFieldArgs, UserSearchResultCollection: UserSearchResultCollectionFieldArgs, Version: VersionFieldArgs, diff --git a/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts b/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts index f72e17868..8c4293548 100644 --- a/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts +++ b/packages/frontend-2/lib/gendo/graphql/queriesAndMutations.ts @@ -2,6 +2,19 @@ import { graphql } from '~~/lib/common/generated/gql' // TODO: Clean up these operations and make them component fragment based. Also some of the props requested don't seem to even be used +export const activeUserGendoLimits = graphql(` + query ActiveUserGendoLimits { + activeUser { + id + gendoAICredits { + used + limit + resetDate + } + } + } +`) + export const requestGendoAIRender = graphql(` mutation requestGendoAIRender($input: GendoAIRenderInput!) { versionMutations { diff --git a/packages/server/app.ts b/packages/server/app.ts index e5a7592b6..52417d7cf 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -378,9 +378,9 @@ export async function init() { app.use(corsMiddleware()) // there are some paths, that need the raw body app.use((req, res, next) => { - const rawPaths = ['/api/v1/billing/webhooks'] - if (rawPaths.includes(req.path)) { - express.raw({ type: 'application/json' })(req, res, next) + const rawPaths = ['/api/v1/billing/webhooks', '/api/thirdparty/gendo/'] + if (rawPaths.some((p) => req.path.startsWith(p))) { + express.raw({ type: 'application/json', limit: '100mb' })(req, res, next) } else { express.json({ limit: '100mb' })(req, res, next) } diff --git a/packages/server/assets/gendo/typedefs/gendo.graphql b/packages/server/assets/gendo/typedefs/gendo.graphql index a5a868cc1..28a0e0df5 100644 --- a/packages/server/assets/gendo/typedefs/gendo.graphql +++ b/packages/server/assets/gendo/typedefs/gendo.graphql @@ -50,3 +50,13 @@ extend type Subscription { projectVersionGendoAIRenderCreated(id: String!, versionId: String!): GendoAIRender! projectVersionGendoAIRenderUpdated(id: String!, versionId: String!): GendoAIRender! } + +type UserGendoAICredits { + used: Int! + limit: Int! + resetDate: DateTime! +} + +extend type User { + gendoAICredits: UserGendoAICredits! @isOwner +} diff --git a/packages/server/modules/blobstorage/domain/operations.ts b/packages/server/modules/blobstorage/domain/operations.ts index 81a290f0e..71d043881 100644 --- a/packages/server/modules/blobstorage/domain/operations.ts +++ b/packages/server/modules/blobstorage/domain/operations.ts @@ -33,12 +33,6 @@ export type GetBlobMetadataCollection = (params: { }) => Promise<{ blobs: BlobStorageItem[]; cursor: Nullable }> export type UploadFileStream = ( - storeFileStream: (params: { - objectKey: string - fileStream: Readable | Buffer - }) => Promise<{ - fileHash: string - }>, params1: { streamId: string userId: string | undefined @@ -50,3 +44,10 @@ export type UploadFileStream = ( fileStream: Readable | Buffer } ) => Promise<{ blobId: string; fileName: string; fileHash: string }> + +type FileStream = string | Blob | Readable | Uint8Array | Buffer + +export type StoreFileStream = (args: { + objectKey: string + fileStream: FileStream +}) => Promise<{ fileHash: string }> diff --git a/packages/server/modules/blobstorage/index.ts b/packages/server/modules/blobstorage/index.ts index 72a31eb94..1d5f94742 100644 --- a/packages/server/modules/blobstorage/index.ts +++ b/packages/server/modules/blobstorage/index.ts @@ -136,6 +136,7 @@ export const init: SpeckleModule['init'] = async (app) => { const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) const uploadFileStream = uploadFileStreamFactory({ + storeFileStream, upsertBlob: upsertBlobFactory({ db: projectDb }), updateBlob }) @@ -178,7 +179,6 @@ export const init: SpeckleModule['init'] = async (app) => { req.log = req.log.child({ blobId }) uploadOperations[blobId] = uploadFileStream( - storeFileStream, { streamId, userId: req.context.userId }, { blobId, fileName, fileType, fileStream: file } ) diff --git a/packages/server/modules/blobstorage/objectStorage.ts b/packages/server/modules/blobstorage/objectStorage.ts index c838f11e5..8ae08dfe2 100644 --- a/packages/server/modules/blobstorage/objectStorage.ts +++ b/packages/server/modules/blobstorage/objectStorage.ts @@ -14,7 +14,7 @@ import { S3ClientConfig, ServiceOutputTypes } from '@aws-sdk/client-s3' -import { Upload, Options as UploadOptions } from '@aws-sdk/lib-storage' +import { Upload } from '@aws-sdk/lib-storage' import { getS3AccessKey, getS3SecretKey, @@ -27,6 +27,7 @@ import { ensureError, Nullable } from '@speckle/shared' import { get } from 'lodash' import type { Command } from '@aws-sdk/smithy-client' import type stream from 'stream' +import { StoreFileStream } from '@/modules/blobstorage/domain/operations' let s3Config: Nullable = null @@ -92,13 +93,7 @@ export const getObjectAttributes = async ({ objectKey }: { objectKey: string }) return { fileSize: data.ContentLength || 0 } } -export const storeFileStream = async ({ - objectKey, - fileStream -}: { - objectKey: string - fileStream: UploadOptions['params']['Body'] -}) => { +export const storeFileStream: StoreFileStream = async ({ objectKey, fileStream }) => { const { client, Bucket } = getObjectStorage() const parallelUploads3 = new Upload({ client, @@ -111,10 +106,6 @@ export const storeFileStream = async ({ leavePartsOnError: false // optional manually handle dropped parts }) - // parallelUploads3.on('httpUploadProgress', (progress) => { - // logger.debug(progress) - // }) - const data = await parallelUploads3.done() // the ETag is a hash of the object. Could be used to dedupe stuff... diff --git a/packages/server/modules/blobstorage/services/management.ts b/packages/server/modules/blobstorage/services/management.ts index 94ce4a9da..522fa4307 100644 --- a/packages/server/modules/blobstorage/services/management.ts +++ b/packages/server/modules/blobstorage/services/management.ts @@ -1,6 +1,7 @@ import { DeleteBlob, GetBlobMetadata, + StoreFileStream, UpdateBlob, UploadFileStream, UpsertBlob @@ -16,8 +17,12 @@ import { MaybeAsync } from '@speckle/shared' export const getFileSizeLimit = () => getFileSizeLimitMB() * 1024 * 1024 export const uploadFileStreamFactory = - (deps: { upsertBlob: UpsertBlob; updateBlob: UpdateBlob }): UploadFileStream => - async (storeFileStream, params1, params2) => { + (deps: { + upsertBlob: UpsertBlob + updateBlob: UpdateBlob + storeFileStream: StoreFileStream + }): UploadFileStream => + async (params1, params2) => { const { streamId, userId } = params1 const { blobId, fileName, fileType, fileStream } = params2 @@ -39,7 +44,7 @@ export const uploadFileStreamFactory = // even might fire faster, than the db insert, causing missing asset data in the db await deps.upsertBlob(dbFile) - const { fileHash } = await storeFileStream({ objectKey, fileStream }) + const { fileHash } = await deps.storeFileStream({ objectKey, fileStream }) // here we should also update the blob db record with the fileHash await deps.updateBlob({ id: blobId, item: { fileHash } }) diff --git a/packages/server/modules/blobstorage/tests/blobstorage.spec.js b/packages/server/modules/blobstorage/tests/blobstorage.spec.js index bb15ff5f7..cf6f86910 100644 --- a/packages/server/modules/blobstorage/tests/blobstorage.spec.js +++ b/packages/server/modules/blobstorage/tests/blobstorage.spec.js @@ -23,12 +23,14 @@ const { cursorFromRows, decodeCursor } = require('@/modules/blobstorage/helpers/ const { createTestStream } = require('@/test/speckle-helpers/streamHelper') const cryptoRandomString = require('crypto-random-string') const { createTestUser } = require('@/test/authHelper') +const { storeFileStream } = require('@/modules/blobstorage/objectStorage') const fakeFileStreamStore = (fakeHash) => async () => ({ fileHash: fakeHash }) const upsertBlob = upsertBlobFactory({ db }) const updateBlob = updateBlobFactory({ db }) const uploadFileStream = uploadFileStreamFactory({ upsertBlob, - updateBlob + updateBlob, + storeFileStream }) const getBlobMetadata = getBlobMetadataFactory({ db }) const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ db }) @@ -66,7 +68,7 @@ describe('Blob storage @blobstorage', () => { data.map(([caseName, streamData, blobData]) => it(`Should throw if ${caseName} id length is incorrect`, async () => { try { - await uploadFileStream(null, streamData, blobData) + await uploadFileStream(streamData, blobData) } catch (err) { if (!(err instanceof BadRequestError)) throw err expect(err.message).to.equal(`The ${caseName} id has to be of length 10`) @@ -81,8 +83,13 @@ describe('Blob storage @blobstorage', () => { const userId = fakeIdGenerator() const fileHash = fakeIdGenerator() + const uploadFileStream = uploadFileStreamFactory({ + upsertBlob, + updateBlob, + storeFileStream: fakeFileStreamStore(fileHash) + }) + const blobData = await uploadFileStream( - fakeFileStreamStore(fileHash), { streamId, userId }, { blobId, fileName, fileType: '.something', fileStream: null } ) diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 3501f56a4..2829184aa 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -3577,6 +3577,7 @@ export type User = { * @deprecated Part of the old API surface and will be removed in the future. */ favoriteStreams: StreamCollection; + gendoAICredits: UserGendoAiCredits; /** Whether the user has a pending/active email verification token */ hasPendingVerification?: Maybe; id: Scalars['ID']['output']; @@ -3765,6 +3766,13 @@ export type UserEmailMutationsSetPrimaryArgs = { input: SetPrimaryUserEmailInput; }; +export type UserGendoAiCredits = { + __typename?: 'UserGendoAICredits'; + limit: Scalars['Int']['output']; + resetDate: Scalars['DateTime']['output']; + used: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; @@ -4717,6 +4725,7 @@ export type ResolversTypes = { UserDeleteInput: UserDeleteInput; UserEmail: ResolverTypeWrapper; UserEmailMutations: ResolverTypeWrapper; + UserGendoAICredits: ResolverTypeWrapper; UserProjectsFilter: UserProjectsFilter; UserProjectsUpdatedMessage: ResolverTypeWrapper & { project?: Maybe }>; UserProjectsUpdatedMessageType: UserProjectsUpdatedMessageType; @@ -4975,6 +4984,7 @@ export type ResolversParentTypes = { UserDeleteInput: UserDeleteInput; UserEmail: UserEmail; UserEmailMutations: MutationsObjectGraphQLReturn; + UserGendoAICredits: UserGendoAiCredits; UserProjectsFilter: UserProjectsFilter; UserProjectsUpdatedMessage: Omit & { project?: Maybe }; UserRoleInput: UserRoleInput; @@ -6243,6 +6253,7 @@ export type UserResolvers, ParentType, ContextType>; expiredSsoSessions?: Resolver, ParentType, ContextType>; favoriteStreams?: Resolver>; + gendoAICredits?: Resolver; hasPendingVerification?: Resolver, ParentType, ContextType>; id?: Resolver; isOnboardingFinished?: Resolver, ParentType, ContextType>; @@ -6286,6 +6297,13 @@ export type UserEmailMutationsResolvers; }; +export type UserGendoAiCreditsResolvers = { + limit?: Resolver; + resetDate?: Resolver; + used?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UserProjectsUpdatedMessageResolvers = { id?: Resolver; project?: Resolver, ParentType, ContextType>; @@ -6653,6 +6671,7 @@ export type Resolvers = { UserAutomateInfo?: UserAutomateInfoResolvers; UserEmail?: UserEmailResolvers; UserEmailMutations?: UserEmailMutationsResolvers; + UserGendoAICredits?: UserGendoAiCreditsResolvers; UserProjectsUpdatedMessage?: UserProjectsUpdatedMessageResolvers; UserSearchResultCollection?: UserSearchResultCollectionResolvers; Version?: VersionResolvers; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 50413575e..c290c4192 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -3558,6 +3558,7 @@ export type User = { * @deprecated Part of the old API surface and will be removed in the future. */ favoriteStreams: StreamCollection; + gendoAICredits: UserGendoAiCredits; /** Whether the user has a pending/active email verification token */ hasPendingVerification?: Maybe; id: Scalars['ID']['output']; @@ -3746,6 +3747,13 @@ export type UserEmailMutationsSetPrimaryArgs = { input: SetPrimaryUserEmailInput; }; +export type UserGendoAiCredits = { + __typename?: 'UserGendoAICredits'; + limit: Scalars['Int']['output']; + resetDate: Scalars['DateTime']['output']; + used: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; diff --git a/packages/server/modules/gatekeeper/migrations/20241120063859_cascade_delete_checkout_session.ts b/packages/server/modules/gatekeeper/migrations/20241120063859_cascade_delete_checkout_session.ts new file mode 100644 index 000000000..a310bef8a --- /dev/null +++ b/packages/server/modules/gatekeeper/migrations/20241120063859_cascade_delete_checkout_session.ts @@ -0,0 +1,32 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('workspace_checkout_sessions', (table) => { + table.dropForeign(['workspaceId']) + table + .foreign('workspaceId') + .references('id') + .inTable('workspaces') + .onDelete('CASCADE') + }) + await knex.schema.alterTable('workspace_subscriptions', (table) => { + table.dropForeign(['workspaceId']) + table + .foreign('workspaceId') + .references('id') + .inTable('workspaces') + .onDelete('CASCADE') + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('workspace_checkout_sessions', (table) => { + table.dropForeign(['workspaceId']) + table.foreign('workspaceId').references('id').inTable('workspaces') + }) + + await knex.schema.alterTable('workspace_subscriptions', (table) => { + table.dropForeign(['workspaceId']) + table.foreign('workspaceId').references('id').inTable('workspaces') + }) +} diff --git a/packages/server/modules/gendo/clients/gendo.ts b/packages/server/modules/gendo/clients/gendo.ts new file mode 100644 index 000000000..af26558c5 --- /dev/null +++ b/packages/server/modules/gendo/clients/gendo.ts @@ -0,0 +1,59 @@ +import { RequestNewImageGeneration } from '@/modules/gendo/domain/operations' +import { GendoRenderRequestError } from '@/modules/gendo/errors/main' + +/** + * +{ + "userId": "user1234", + "prompt": "a beautiful stone barn next to a lake, sunset, water", + "webhookUrl": "", + "depthMapBase64": "" +} + */ + +export const requestNewImageGenerationFactory = + ({ + endpoint, + token, + serverOrigin + }: { + endpoint: string + token: string + serverOrigin: string + }): RequestNewImageGeneration => + async (input) => { + const webhookUrl = `${serverOrigin}/api/thirdparty/gendo/${input.projectId}` + + // TODO: Fn handles too many concerns, refactor (e.g. the client fetch call) + // TODO: Fire off request to gendo api & get generationId, create record in db. Note: use gendo api key from env + const depthMapBase64 = input.baseImage.replace(/^data:image\/\w+;base64,/, '') + const gendoRequestBody = { + userId: input.userId, + depthMapBase64, + prompt: input.prompt, + webhookUrl + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-token': token + }, + body: JSON.stringify(gendoRequestBody) + }) + + const status = response.status + if (status !== 201) { + const body = await response.json().catch((e) => ({ error: `${e}` })) + throw new GendoRenderRequestError('Failed to enqueue gendo render.', { + info: { body } + }) + } + + const gendoResponseBody = (await response.json()) as { + status: string + generationId: string + } + return gendoResponseBody + } diff --git a/packages/server/modules/gendo/domain/operations.ts b/packages/server/modules/gendo/domain/operations.ts index 3f96497b4..a5088342d 100644 --- a/packages/server/modules/gendo/domain/operations.ts +++ b/packages/server/modules/gendo/domain/operations.ts @@ -1,5 +1,5 @@ import { GendoAiRenderInput } from '@/modules/core/graph/generated/graphql' -import { GendoAIRender } from '@/modules/gendo/domain/types' +import { GendoAIRender, UserCredits } from '@/modules/gendo/domain/types' import { NullableKeysToOptional, Optional } from '@speckle/shared' import { SetOptional } from 'type-fest' @@ -33,5 +33,16 @@ export type CreateRenderRequest = ( export type UpdateRenderRequest = (input: { responseImage: string + status: string gendoGenerationId: string }) => Promise + +export type RequestNewImageGeneration = (args: { + userId: string + baseImage: string + projectId: string + prompt: string +}) => Promise<{ status: string; generationId: string }> + +export type GetUserCredits = (args: { userId: string }) => Promise +export type UpsertUserCredits = (args: { userCredits: UserCredits }) => Promise diff --git a/packages/server/modules/gendo/domain/types.ts b/packages/server/modules/gendo/domain/types.ts index 19f7b30a2..3b0f6c67c 100644 --- a/packages/server/modules/gendo/domain/types.ts +++ b/packages/server/modules/gendo/domain/types.ts @@ -1,3 +1,5 @@ import { GendoAIRenderRecord } from '@/modules/gendo/helpers/types' export type GendoAIRender = GendoAIRenderRecord + +export type UserCredits = { userId: string; used: number; resetDate: Date } diff --git a/packages/server/modules/gendo/errors/main.ts b/packages/server/modules/gendo/errors/main.ts index c84e415d9..ba2ab820e 100644 --- a/packages/server/modules/gendo/errors/main.ts +++ b/packages/server/modules/gendo/errors/main.ts @@ -11,3 +11,10 @@ export class GendoRenderRequestNotFoundError extends BaseError { static defaultMessage = 'Gendo render request not found' static statusCode = 404 } + +export class InsufficientGendoRenderCreditsError extends BaseError { + static code = 'INSUFFICIENT_GENDO_RENDER_CREDITS' + static defaultMessage = + 'You do not have enough GendoAi credits left for the operation' + static statusCode = 402 +} diff --git a/packages/server/modules/gendo/graph/resolvers/index.ts b/packages/server/modules/gendo/graph/resolvers/index.ts index 8bf2c5a7c..9a8db6394 100644 --- a/packages/server/modules/gendo/graph/resolvers/index.ts +++ b/packages/server/modules/gendo/graph/resolvers/index.ts @@ -20,114 +20,168 @@ import { import { storeFileStream } from '@/modules/blobstorage/objectStorage' import { getLatestVersionRenderRequestsFactory, + getUserCreditsFactory, getVersionRenderRequestFactory, - storeRenderFactory + storeRenderFactory, + upsertUserCreditsFactory } from '@/modules/gendo/repositories' +import { getProjectDbClient } from '@/modules/multiregion/dbSelector' +import { requestNewImageGenerationFactory } from '@/modules/gendo/clients/gendo' +import { + getUserGendoAiCreditsFactory, + useUserGendoAiCreditsFactory +} from '@/modules/gendo/services/userCredits' import { db } from '@/db/knex' +import { + getGendoAiApiEndpoint, + getGendoAIKey, + getGendoAICreditLimit, + getServerOrigin, + getFeatureFlags +} from '@/modules/shared/helpers/envHelper' -const createRenderRequest = createRenderRequestFactory({ - uploadFileStream: uploadFileStreamFactory({ - upsertBlob: upsertBlobFactory({ db }), - updateBlob: updateBlobFactory({ db }) - }), - storeFileStream, - storeRender: storeRenderFactory({ db }), - publish, - fetch +const upsertUserCredits = upsertUserCreditsFactory({ db }) +const getUserGendoAiCredits = getUserGendoAiCreditsFactory({ + getUserCredits: getUserCreditsFactory({ db }), + upsertUserCredits }) -const getLatestVersionRenderRequests = getLatestVersionRenderRequestsFactory({ db }) -const getVersionRenderRequest = getVersionRenderRequestFactory({ db }) -export = { - Version: { - async gendoAIRenders(parent) { - const items = await getLatestVersionRenderRequests({ versionId: parent.id }) - return { - totalCount: items.length, - items - } - }, - async gendoAIRender(parent, args) { - const item = await getVersionRenderRequest({ - versionId: parent.id, - id: args.id - }) +const { FF_GENDOAI_MODULE_ENABLED } = getFeatureFlags() - return item - } - }, - GendoAIRender: { - async user(parent, __args, ctx) { - return await ctx.loaders.users.getUser.load(parent.userId) - } - }, - VersionMutations: { - async requestGendoAIRender(__parent, args, ctx) { - await authorizeResolver( - ctx.userId, - args.input.projectId, - Roles.Stream.Reviewer, - ctx.resourceAccessRules - ) - - const rateLimitResult = await getRateLimitResult( - 'GENDO_AI_RENDER_REQUEST', - ctx.userId as string - ) - if (isRateLimitBreached(rateLimitResult)) { - throw new RateLimitError(rateLimitResult) - } - - await createRenderRequest({ - ...args.input, - userId: ctx.userId! - }) - - return true - } - }, - Subscription: { - projectVersionGendoAIRenderCreated: { - subscribe: filteredSubscribe( - ProjectSubscriptions.ProjectVersionGendoAIRenderCreated, - async (payload, args, ctx) => { - if ( - args.id !== payload.projectVersionGendoAIRenderCreated.projectId || - args.versionId !== payload.projectVersionGendoAIRenderCreated.versionId - ) - return false +export = FF_GENDOAI_MODULE_ENABLED + ? ({ + Version: { + async gendoAIRenders(parent) { + const projectDb = await getProjectDbClient({ projectId: parent.streamId }) + const items = await getLatestVersionRenderRequestsFactory({ db: projectDb })({ + versionId: parent.id + }) + return { + totalCount: items.length, + items + } + }, + async gendoAIRender(parent, args) { + const projectDb = await getProjectDbClient({ projectId: parent.streamId }) + const item = await getVersionRenderRequestFactory({ db: projectDb })({ + versionId: parent.id, + id: args.id + }) + return item + } + }, + GendoAIRender: { + async user(parent, __args, ctx) { + return await ctx.loaders.users.getUser.load(parent.userId) + } + }, + VersionMutations: { + async requestGendoAIRender(__parent, args, ctx) { await authorizeResolver( ctx.userId, - args.id, + args.input.projectId, Roles.Stream.Reviewer, ctx.resourceAccessRules ) - return true - } - ) - }, - projectVersionGendoAIRenderUpdated: { - subscribe: filteredSubscribe( - ProjectSubscriptions.ProjectVersionGendoAIRenderUpdated, - async (payload, args, ctx) => { - if ( - args.id !== payload.projectVersionGendoAIRenderUpdated.projectId || - args.versionId !== payload.projectVersionGendoAIRenderUpdated.versionId + const rateLimitResult = await getRateLimitResult( + 'GENDO_AI_RENDER_REQUEST', + ctx.userId as string ) - return false + if (isRateLimitBreached(rateLimitResult)) { + throw new RateLimitError(rateLimitResult) + } - await authorizeResolver( - ctx.userId, - args.id, - Roles.Stream.Reviewer, - ctx.resourceAccessRules - ) + const userId = ctx.userId! + + const projectDb = await getProjectDbClient({ + projectId: args.input.projectId + }) + + await useUserGendoAiCreditsFactory({ + getUserGendoAiCredits, + upsertUserCredits, + maxCredits: getGendoAICreditLimit() + })({ userId, credits: 1 }) + + const requestNewImageGeneration = requestNewImageGenerationFactory({ + endpoint: getGendoAiApiEndpoint(), + serverOrigin: getServerOrigin(), + token: getGendoAIKey() + }) + + const createRenderRequest = createRenderRequestFactory({ + uploadFileStream: uploadFileStreamFactory({ + storeFileStream, + upsertBlob: upsertBlobFactory({ db: projectDb }), + updateBlob: updateBlobFactory({ db: projectDb }) + }), + requestNewImageGeneration, + storeRender: storeRenderFactory({ db: projectDb }), + publish + }) + + await createRenderRequest({ + ...args.input, + userId + }) return true } - ) - } - } -} as Resolvers + }, + User: { + gendoAICredits: async (_parent, _args, ctx) => { + const userCredits = await getUserGendoAiCredits({ userId: ctx.userId! }) + return { + limit: getGendoAICreditLimit(), + ...userCredits + } + } + }, + Subscription: { + projectVersionGendoAIRenderCreated: { + subscribe: filteredSubscribe( + ProjectSubscriptions.ProjectVersionGendoAIRenderCreated, + async (payload, args, ctx) => { + if ( + args.id !== payload.projectVersionGendoAIRenderCreated.projectId || + args.versionId !== payload.projectVersionGendoAIRenderCreated.versionId + ) + return false + + await authorizeResolver( + ctx.userId, + args.id, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) + + return true + } + ) + }, + projectVersionGendoAIRenderUpdated: { + subscribe: filteredSubscribe( + ProjectSubscriptions.ProjectVersionGendoAIRenderUpdated, + async (payload, args, ctx) => { + if ( + args.id !== payload.projectVersionGendoAIRenderUpdated.projectId || + args.versionId !== payload.projectVersionGendoAIRenderUpdated.versionId + ) + return false + + await authorizeResolver( + ctx.userId, + args.id, + Roles.Stream.Reviewer, + ctx.resourceAccessRules + ) + + return true + } + ) + } + } + } as Resolvers) + : {} diff --git a/packages/server/modules/gendo/migrations/20241120140402_gendo_credits.ts b/packages/server/modules/gendo/migrations/20241120140402_gendo_credits.ts new file mode 100644 index 000000000..27cce7ff7 --- /dev/null +++ b/packages/server/modules/gendo/migrations/20241120140402_gendo_credits.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('gendo_user_credits', (table) => { + table + .string('userId') + .primary() + .references('id') + .inTable('users') + .onDelete('cascade') + table.timestamp('resetDate', { precision: 3, useTz: true }).notNullable() + table.integer('used').notNullable() + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('gendo_user_credits') +} diff --git a/packages/server/modules/gendo/repositories/index.ts b/packages/server/modules/gendo/repositories/index.ts index c16f7adbc..1a1cee585 100644 --- a/packages/server/modules/gendo/repositories/index.ts +++ b/packages/server/modules/gendo/repositories/index.ts @@ -2,16 +2,20 @@ import { GendoAIRenders } from '@/modules/core/dbSchema' import { GetLatestVersionRenderRequests, GetRenderByGenerationId, + GetUserCredits, GetVersionRenderRequest, StoreRender, - UpdateRenderRecord + UpdateRenderRecord, + UpsertUserCredits } from '@/modules/gendo/domain/operations' +import { UserCredits } from '@/modules/gendo/domain/types' import { GendoAIRenderRecord } from '@/modules/gendo/helpers/types' import { Knex } from 'knex' import { pick } from 'lodash' const tables = { - gendoAIRenders: (db: Knex) => db(GendoAIRenders.name) + gendoAIRenders: (db: Knex) => db(GendoAIRenders.name), + gendoUserCredits: (db: Knex) => db('gendo_user_credits') } export const storeRenderFactory = @@ -68,3 +72,21 @@ export const getVersionRenderRequestFactory = .first() return record } + +export const getUserCreditsFactory = + ({ db }: { db: Knex }): GetUserCredits => + async ({ userId }) => { + const userCredits = await tables + .gendoUserCredits(db) + .select() + .where({ userId }) + .first() + + return userCredits || null + } + +export const upsertUserCreditsFactory = + ({ db }: { db: Knex }): UpsertUserCredits => + async ({ userCredits }) => { + await tables.gendoUserCredits(db).insert(userCredits).onConflict('userId').merge() + } diff --git a/packages/server/modules/gendo/rest/index.ts b/packages/server/modules/gendo/rest/index.ts index 19a44a837..2e9c5611c 100644 --- a/packages/server/modules/gendo/rest/index.ts +++ b/packages/server/modules/gendo/rest/index.ts @@ -1,12 +1,10 @@ import { corsMiddleware } from '@/modules/core/configs/cors' -import { getGendoAIResponseKey } from '@/modules/shared/helpers/envHelper' import { updateRenderRequestFactory } from '@/modules/gendo/services' import type express from 'express' import { getRenderByGenerationIdFactory, updateRenderRecordFactory } from '@/modules/gendo/repositories' -import { db } from '@/db/knex' import { uploadFileStreamFactory } from '@/modules/blobstorage/services/management' import { storeFileStream } from '@/modules/blobstorage/objectStorage' import { @@ -14,34 +12,57 @@ import { upsertBlobFactory } from '@/modules/blobstorage/repositories' import { publish } from '@/modules/shared/utils/subscriptions' +import { getProjectDbClient } from '@/modules/multiregion/dbSelector' +import { createHmac, timingSafeEqual } from 'node:crypto' +import { getGendoAIKey } from '@/modules/shared/helpers/envHelper' +//Validate payload +// function validatePayload(req, res, next) { +// if (req.get(sigHeaderName)) { +// //Extract Signature header +// +// } + +// return next(); +// } export default function (app: express.Express) { - const responseToken = getGendoAIResponseKey() - const updateRenderRequest = updateRenderRequestFactory({ - getRenderByGenerationId: getRenderByGenerationIdFactory({ db }), - uploadFileStream: uploadFileStreamFactory({ - upsertBlob: upsertBlobFactory({ db }), - updateBlob: updateBlobFactory({ db }) - }), - storeFileStream, - updateRenderRecord: updateRenderRecordFactory({ db }), - publish - }) + // const responseToken = getGendoAIResponseKey() // Gendo api calls hit these endpoints w/ the results - app.options('/api/thirdparty/gendo', corsMiddleware()) - app.post('/api/thirdparty/gendo', corsMiddleware(), async (req, res) => { - if (req.headers['x-gendo-authorization'] !== responseToken) { - return res.status(401).send('Speckle says you are not authorized 😠') - } + app.options('/api/thirdparty/gendo/:projectId', corsMiddleware()) + app.post('/api/thirdparty/gendo/:projectId', corsMiddleware(), async (req, res) => { + const sig = Buffer.from(req.get('x-signature-sha256') || '', 'utf8') - const responseImage = req.body.generated_image - // const status = req.body.status - const gendoGenerationId = req.body.generationId + // //Calculate HMAC + const hmac = createHmac('sha256', getGendoAIKey()) + const digest = Buffer.from(hmac.update(req.body).digest('base64'), 'utf-8') + + // //Compare HMACs + if (sig.length !== digest.length || !timingSafeEqual(digest, sig)) { + return res.status(401).send('Speckle says your webhook signature is not valid 😠') + } + const payload = JSON.parse(req.body) + const responseImage = payload.imageBase64 + const status = payload.status + const gendoGenerationId = payload.generationId + + const projectDb = await getProjectDbClient({ projectId: req.params.projectId }) + + const updateRenderRequest = updateRenderRequestFactory({ + getRenderByGenerationId: getRenderByGenerationIdFactory({ db: projectDb }), + uploadFileStream: uploadFileStreamFactory({ + storeFileStream, + upsertBlob: upsertBlobFactory({ db: projectDb }), + updateBlob: updateBlobFactory({ db: projectDb }) + }), + updateRenderRecord: updateRenderRecordFactory({ db: projectDb }), + publish + }) await updateRenderRequest({ gendoGenerationId, - responseImage + responseImage, + status }) res.status(200).send('Speckle says thank you 💖') diff --git a/packages/server/modules/gendo/services/index.ts b/packages/server/modules/gendo/services/index.ts index 5f5fffa00..272662e73 100644 --- a/packages/server/modules/gendo/services/index.ts +++ b/packages/server/modules/gendo/services/index.ts @@ -3,69 +3,33 @@ import { ProjectSubscriptions, PublishSubscription } from '@/modules/shared/utils/subscriptions' -import { storeFileStream } from '@/modules/blobstorage/objectStorage' import { CreateRenderRequest, GetRenderByGenerationId, + RequestNewImageGeneration, StoreRender, UpdateRenderRecord, UpdateRenderRequest } from '@/modules/gendo/domain/operations' import { UploadFileStream } from '@/modules/blobstorage/domain/operations' -import { - getGendoAIAPIEndpoint, - getGendoAIKey, - getServerOrigin -} from '@/modules/shared/helpers/envHelper' -import { - GendoRenderRequestError, - GendoRenderRequestNotFoundError -} from '@/modules/gendo/errors/main' +import { GendoRenderRequestNotFoundError } from '@/modules/gendo/errors/main' export const createRenderRequestFactory = (deps: { + requestNewImageGeneration: RequestNewImageGeneration uploadFileStream: UploadFileStream - storeFileStream: typeof storeFileStream storeRender: StoreRender publish: PublishSubscription - fetch: typeof fetch }): CreateRenderRequest => async (input) => { - const endpoint = getGendoAIAPIEndpoint() - const bearer = getGendoAIKey() as string - const webhookUrl = `${getServerOrigin()}/api/thirdparty/gendo` - - // TODO: Fn handles too many concerns, refactor (e.g. the client fetch call) - // TODO: Fire off request to gendo api & get generationId, create record in db. Note: use gendo api key from env - const gendoRequestBody = { + const imageRequest = await deps.requestNewImageGeneration({ userId: input.userId, - depthMap: input.baseImage, + baseImage: input.baseImage, prompt: input.prompt, - webhookUrl - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${bearer}` - }, - body: JSON.stringify(gendoRequestBody) + projectId: input.projectId }) - - const status = response.status - if (status !== 200) { - const body = await response.json().catch((e) => ({ error: `${e}` })) - throw new GendoRenderRequestError('Failed to enqueue gendo render.', { - info: { body } - }) - } - - const gendoResponseBody = (await response.json()) as { - status: string - generationId: string - } + // ---- const baseImageBuffer = Buffer.from( input.baseImage.replace(/^data:image\/\w+;base64,/, ''), 'base64' @@ -73,7 +37,6 @@ export const createRenderRequestFactory = const blobId = crs({ length: 10 }) await deps.uploadFileStream( - deps.storeFileStream, { streamId: input.projectId, userId: input.userId }, { blobId, @@ -87,8 +50,8 @@ export const createRenderRequestFactory = const newRecord = await deps.storeRender({ ...input, - status: gendoResponseBody.status, - gendoGenerationId: gendoResponseBody.generationId, + status: imageRequest.status, + gendoGenerationId: imageRequest.generationId, id: crs({ length: 10 }) }) @@ -105,7 +68,6 @@ export const updateRenderRequestFactory = (deps: { getRenderByGenerationId: GetRenderByGenerationId uploadFileStream: UploadFileStream - storeFileStream: typeof storeFileStream updateRenderRecord: UpdateRenderRecord publish: PublishSubscription }): UpdateRenderRequest => @@ -131,7 +93,6 @@ export const updateRenderRequestFactory = const blobId = crs({ length: 10 }) await deps.uploadFileStream( - deps.storeFileStream, { streamId: baseRequest.projectId, userId: baseRequest.userId }, { blobId, diff --git a/packages/server/modules/gendo/services/userCredits.ts b/packages/server/modules/gendo/services/userCredits.ts new file mode 100644 index 000000000..42a044588 --- /dev/null +++ b/packages/server/modules/gendo/services/userCredits.ts @@ -0,0 +1,50 @@ +import { GetUserCredits, UpsertUserCredits } from '@/modules/gendo/domain/operations' +import { UserCredits } from '@/modules/gendo/domain/types' +import { InsufficientGendoRenderCreditsError } from '@/modules/gendo/errors/main' +import dayjs from 'dayjs' + +type GetUserGendoAiCredits = (args: { userId: string }) => Promise + +export const getUserGendoAiCreditsFactory = + ({ + getUserCredits, + upsertUserCredits + }: { + getUserCredits: GetUserCredits + upsertUserCredits: UpsertUserCredits + }) => + async ({ userId }: { userId: string }) => { + // + const userCredits = await getUserCredits({ userId }) + if (userCredits && userCredits.resetDate.getTime() > new Date().getTime()) + return userCredits + + const resetDate = dayjs(userCredits?.resetDate || new Date()) + .add(1, 'month') + .toDate() + + const newCredits = { + used: 0, + userId, + resetDate + } + await upsertUserCredits({ userCredits: newCredits }) + return newCredits + } + +export const useUserGendoAiCreditsFactory = + ({ + getUserGendoAiCredits, + upsertUserCredits, + maxCredits + }: { + getUserGendoAiCredits: GetUserGendoAiCredits + upsertUserCredits: UpsertUserCredits + maxCredits: number + }) => + async ({ userId, credits }: { userId: string; credits: number }) => { + const userCredits = await getUserGendoAiCredits({ userId }) + userCredits.used += credits + if (userCredits.used > maxCredits) throw new InsufficientGendoRenderCreditsError() + await upsertUserCredits({ userCredits }) + } diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 654e9ca04..5db03dcad 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -350,11 +350,11 @@ export function getGendoAIKey() { return getStringFromEnv('GENDOAI_KEY') } -export function getGendoAIResponseKey() { - return getStringFromEnv('GENDOAI_KEY_RESPONSE') +export function getGendoAICreditLimit() { + return getIntFromEnv('GENDOAI_CREDIT_LIMIT') } -export function getGendoAIAPIEndpoint() { +export function getGendoAiApiEndpoint() { return getStringFromEnv('GENDOAI_API_ENDPOINT') } diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index ad9035008..8b8f3f0d8 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -3559,6 +3559,7 @@ export type User = { * @deprecated Part of the old API surface and will be removed in the future. */ favoriteStreams: StreamCollection; + gendoAICredits: UserGendoAiCredits; /** Whether the user has a pending/active email verification token */ hasPendingVerification?: Maybe; id: Scalars['ID']['output']; @@ -3747,6 +3748,13 @@ export type UserEmailMutationsSetPrimaryArgs = { input: SetPrimaryUserEmailInput; }; +export type UserGendoAiCredits = { + __typename?: 'UserGendoAICredits'; + limit: Scalars['Int']['output']; + resetDate: Scalars['DateTime']['output']; + used: Scalars['Int']['output']; +}; + export type UserProjectsFilter = { /** Only include projects where user has the specified roles */ onlyWithRoles?: InputMaybe>; diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 16b83b4c3..83c80b32e 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -731,15 +731,12 @@ Generate the environment variables for Speckle server and Speckle objects deploy name: {{ default .Values.secretName .Values.server.gendoAI.key.secretName }} key: {{ .Values.server.gendoAI.key.secretKey }} -- name: GENDOAI_KEY_RESPONSE - valueFrom: - secretKeyRef: - name: {{ default .Values.secretName .Values.server.gendoAI.keyResponse.secretName }} - key: {{ .Values.server.gendoAI.keyResponse.secretKey }} - - name: GENDOAI_API_ENDPOINT value: {{ .Values.server.gendoAI.apiUrl | quote }} +- name: GENDOAI_CREDIT_LIMIT + value: {{ .Values.server.gendoAI.creditLimit | quote }} + - name: RATELIMIT_GENDO_AI_RENDER_REQUEST value: {{ .Values.server.gendoai.ratelimiting.renderRequest | quote }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 60590b3af..ed076530b 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -644,6 +644,11 @@ "description": "The url of the Gendo AI application, including protocol.", "default": "https://api.gendo.ai/external/generate" }, + "creditLimit": { + "type": "number", + "description": "The number of Gendo AI credit a user gets every month.", + "default": 40 + }, "key": { "type": "object", "properties": { @@ -659,21 +664,6 @@ } } }, - "keyResponse": { - "type": "object", - "properties": { - "secretName": { - "type": "string", - "description": "The name of the Kubernetes Secret containing the Gendo AI key response. If left blank, will default to the `secretName` parameter.", - "default": "" - }, - "secretKey": { - "type": "string", - "description": "The key within the Kubernetes Secret holding the Gendo AI key response as its value.", - "default": "gendoai_key_response" - } - } - }, "ratelimiting": { "type": "object", "properties": { diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 4e97e8019..bb496e5e2 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -467,16 +467,14 @@ server: gendoAI: ## @param server.gendoAI.apiUrl The url of the Gendo AI application, including protocol. apiUrl: 'https://api.gendo.ai/external/generate' + + ## @param server.gendoAI.creditLimit The number of Gendo AI credit a user gets every month. + creditLimit: 40 key: ## @param server.gendoAI.key.secretName The name of the Kubernetes Secret containing the Gendo AI key. If left blank, will default to the `secretName` parameter. secretName: '' ## @param server.gendoAI.key.secretKey The key within the Kubernetes Secret holding the Gendo AI key as its value. secretKey: 'gendoai_key' - keyResponse: - ## @param server.gendoAI.keyResponse.secretName The name of the Kubernetes Secret containing the Gendo AI key response. If left blank, will default to the `secretName` parameter. - secretName: '' - ## @param server.gendoAI.keyResponse.secretKey The key within the Kubernetes Secret holding the Gendo AI key response as its value. - secretKey: 'gendoai_key_response' ratelimiting: ## @param server.gendoAI.ratelimiting.renderRequest The number of render requests allowed per period renderRequest: 1 diff --git a/workspace.code-workspace b/workspace.code-workspace index 48902a487..998d7d280 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -95,6 +95,8 @@ "Bursty", "discoverability", "Encryptor", + "Gendo", + "GENDOAI", "Insertable", "mjml", "OIDC",