gergo/web 2155 gendo module multi region (#3528)

* fix(workspaces): allow workspace delete for paid workspaces

* feat(gendo): multi region gendo with new api integration and limits

* feat(gendo): user credits

* feat: adds limits to gendo panel, and terms and conditions link

* fix: reworks setting back camera view

* feat(gendo): webhook signature verification

* fix(gendo): nullability

* test(blobstorage): fix test dependency injection

---------

Co-authored-by: Dimitrie Stefanescu <didimitrie@gmail.com>
This commit is contained in:
Gergő Jedlicska
2024-11-22 16:43:31 +01:00
committed by GitHub
parent 60e724eb4d
commit 01b222ced8
31 changed files with 590 additions and 239 deletions
@@ -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
)
@@ -11,6 +11,7 @@
>
Gendo
</CommonTextLink>
<span class="text-foreground-2">&nbsp;(Beta)</span>
</template>
<div class="p-2">
@@ -23,38 +24,67 @@
placeholder="Your prompt"
/>
<div class="flex justify-end space-x-2 items-center">
<div v-if="limits" class="text-xs text-foreground-2">
You have used {{ limits.used }} out of {{ limits.limit }} monthly free
renders.
</div>
<FormButton
:disabled="!prompt || isLoading || timeOutWait"
v-if="(limits?.used || 1) < (limits?.limit || 0)"
:disabled="
!prompt ||
isLoading ||
timeOutWait ||
(limits?.used || 0) >= (limits?.limit || 0)
"
@click="enqueMagic()"
>
Render
</FormButton>
<FormButton v-else to="https://gendo.ai?utm=speckle" target="_blank">
Visit Gendo
</FormButton>
</div>
</div>
<ViewerGendoList />
</div>
<template #actions>
<div class="text-right grow">
<span class="text-foreground-2 text-sm">Learn more about</span>
<CommonTextLink
text
link
class="ml-1"
to="https://gendo.ai?utm=speckle"
target="_blank"
>
Gendo
</CommonTextLink>
<div class="flex grow items-center justify-between">
<span class="text-foreground-2 text-sm">
<CommonTextLink
text
link
class="mr-2"
to="https://www.gendo.ai/terms-of-service"
target="_blank"
>
Terms and conditions
</CommonTextLink>
</span>
<div>
<span class="text-foreground-2 text-sm">More about</span>
<CommonTextLink
text
link
class="ml-1"
to="https://gendo.ai?utm=speckle"
target="_blank"
>
Gendo
</CommonTextLink>
</div>
</div>
</template>
</ViewerLayoutPanel>
</template>
<script setup lang="ts">
import { useApolloClient } from '@vue/apollo-composable'
import { useApolloClient, useQuery } from '@vue/apollo-composable'
import { useTimeoutFn } from '@vueuse/core'
import { getFirstErrorMessage } from '~/lib/common/helpers/graphql'
import { PassReader } from '~/lib/viewer/extensions/PassReader'
import { requestGendoAIRender } from '~~/lib/gendo/graphql/queriesAndMutations'
import {
requestGendoAIRender,
activeUserGendoLimits
} from '~~/lib/gendo/graphql/queriesAndMutations'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
const {
@@ -74,6 +104,12 @@ const prompt = ref<string>()
const isLoading = ref(false)
const timeOutWait = ref(false)
const { result, refetch } = useQuery(activeUserGendoLimits)
const limits = computed(() => {
return result?.value?.activeUser?.gendoAICredits
})
const enqueMagic = async () => {
isLoading.value = true
const [depthData, width, height] = await viewerInstance
@@ -134,5 +170,6 @@ const lodgeRequest = async (screenshot: string) => {
})
}
isLoading.value = false
refetch()
}
</script>
@@ -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.
*/
@@ -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<Scalars['Boolean']['output']>;
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<Array<Scalars['String']['input']>>;
@@ -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<DeveloperSettingsAuthorizedAppsQuery, DeveloperSettingsAuthorizedAppsQueryVariables>;
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<SearchProjectsQuery, SearchProjectsQueryVariables>;
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<SearchProjectModelsQuery, SearchProjectModelsQueryVariables>;
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<ActiveUserGendoLimitsQuery, ActiveUserGendoLimitsQueryVariables>;
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<RequestGendoAiRenderMutation, RequestGendoAiRenderMutationVariables>;
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<GendoAiRenderQuery, GendoAiRenderQueryVariables>;
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<GendoAiRendersQuery, GendoAiRendersQueryVariables>;
@@ -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,
@@ -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 {
+3 -3
View File
@@ -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)
}
@@ -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
}
@@ -33,12 +33,6 @@ export type GetBlobMetadataCollection = (params: {
}) => Promise<{ blobs: BlobStorageItem[]; cursor: Nullable<string> }>
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 }>
+1 -1
View File
@@ -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 }
)
@@ -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<S3ClientConfig> = 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...
@@ -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 } })
@@ -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 }
)
@@ -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<Scalars['Boolean']['output']>;
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<Array<Scalars['String']['input']>>;
@@ -4717,6 +4725,7 @@ export type ResolversTypes = {
UserDeleteInput: UserDeleteInput;
UserEmail: ResolverTypeWrapper<UserEmail>;
UserEmailMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
UserGendoAICredits: ResolverTypeWrapper<UserGendoAiCredits>;
UserProjectsFilter: UserProjectsFilter;
UserProjectsUpdatedMessage: ResolverTypeWrapper<Omit<UserProjectsUpdatedMessage, 'project'> & { project?: Maybe<ResolversTypes['Project']> }>;
UserProjectsUpdatedMessageType: UserProjectsUpdatedMessageType;
@@ -4975,6 +4984,7 @@ export type ResolversParentTypes = {
UserDeleteInput: UserDeleteInput;
UserEmail: UserEmail;
UserEmailMutations: MutationsObjectGraphQLReturn;
UserGendoAICredits: UserGendoAiCredits;
UserProjectsFilter: UserProjectsFilter;
UserProjectsUpdatedMessage: Omit<UserProjectsUpdatedMessage, 'project'> & { project?: Maybe<ResolversParentTypes['Project']> };
UserRoleInput: UserRoleInput;
@@ -6243,6 +6253,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
emails?: Resolver<Array<ResolversTypes['UserEmail']>, ParentType, ContextType>;
expiredSsoSessions?: Resolver<Array<ResolversTypes['LimitedWorkspace']>, ParentType, ContextType>;
favoriteStreams?: Resolver<ResolversTypes['StreamCollection'], ParentType, ContextType, RequireFields<UserFavoriteStreamsArgs, 'limit'>>;
gendoAICredits?: Resolver<ResolversTypes['UserGendoAICredits'], ParentType, ContextType>;
hasPendingVerification?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isOnboardingFinished?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
@@ -6286,6 +6297,13 @@ export type UserEmailMutationsResolvers<ContextType = GraphQLContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UserGendoAiCreditsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['UserGendoAICredits'] = ResolversParentTypes['UserGendoAICredits']> = {
limit?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
resetDate?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
used?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UserProjectsUpdatedMessageResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['UserProjectsUpdatedMessage'] = ResolversParentTypes['UserProjectsUpdatedMessage']> = {
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
project?: Resolver<Maybe<ResolversTypes['Project']>, ParentType, ContextType>;
@@ -6653,6 +6671,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
UserAutomateInfo?: UserAutomateInfoResolvers<ContextType>;
UserEmail?: UserEmailResolvers<ContextType>;
UserEmailMutations?: UserEmailMutationsResolvers<ContextType>;
UserGendoAICredits?: UserGendoAiCreditsResolvers<ContextType>;
UserProjectsUpdatedMessage?: UserProjectsUpdatedMessageResolvers<ContextType>;
UserSearchResultCollection?: UserSearchResultCollectionResolvers<ContextType>;
Version?: VersionResolvers<ContextType>;
@@ -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<Scalars['Boolean']['output']>;
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<Array<Scalars['String']['input']>>;
@@ -0,0 +1,32 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
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<void> {
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')
})
}
@@ -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": "<speckle gendo webhook handler endpoint>",
"depthMapBase64": "<base64 encoded png depth map>"
}
*/
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
}
@@ -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<GendoAIRender>
export type RequestNewImageGeneration = (args: {
userId: string
baseImage: string
projectId: string
prompt: string
}) => Promise<{ status: string; generationId: string }>
export type GetUserCredits = (args: { userId: string }) => Promise<UserCredits | null>
export type UpsertUserCredits = (args: { userCredits: UserCredits }) => Promise<void>
@@ -1,3 +1,5 @@
import { GendoAIRenderRecord } from '@/modules/gendo/helpers/types'
export type GendoAIRender = GendoAIRenderRecord
export type UserCredits = { userId: string; used: number; resetDate: Date }
@@ -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
}
@@ -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)
: {}
@@ -0,0 +1,18 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
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<void> {
await knex.schema.dropTable('gendo_user_credits')
}
@@ -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<GendoAIRenderRecord>(GendoAIRenders.name)
gendoAIRenders: (db: Knex) => db<GendoAIRenderRecord>(GendoAIRenders.name),
gendoUserCredits: (db: Knex) => db<UserCredits>('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()
}
+43 -22
View File
@@ -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 💖')
@@ -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,
@@ -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<UserCredits>
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 })
}
@@ -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')
}
@@ -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<Scalars['Boolean']['output']>;
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<Array<Scalars['String']['input']>>;
@@ -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 }}
+5 -15
View File
@@ -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": {
+3 -5
View File
@@ -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
+2
View File
@@ -95,6 +95,8 @@
"Bursty",
"discoverability",
"Encryptor",
"Gendo",
"GENDOAI",
"Insertable",
"mjml",
"OIDC",