From 306e5900b7081bf7a56ebd79e4484aead308c7c1 Mon Sep 17 00:00:00 2001 From: Egan Bisma Date: Mon, 29 May 2023 16:58:51 +0700 Subject: [PATCH] message credits integration --- packages/shared/src/types/schema.ts | 6 + packages/supabase/functions/openai/index.ts | 7 +- .../web/src/components/ApiKeySettings.tsx | 228 ++++++++++++++++++ packages/web/src/components/RunFromStart.tsx | 12 +- .../web/src/nodes/templates/NodeTemplate.tsx | 11 +- packages/web/src/pages/app/App.tsx | 21 +- packages/web/src/store/useStore.ts | 12 +- packages/web/src/store/useStoreSecret.ts | 15 ++ packages/web/src/utils/runFlow.ts | 48 +++- packages/web/src/utils/userCredits.ts | 41 ++++ .../windows/SettingsPanel/SettingsPanel.tsx | 35 +-- 11 files changed, 393 insertions(+), 43 deletions(-) create mode 100644 packages/web/src/components/ApiKeySettings.tsx create mode 100644 packages/web/src/utils/userCredits.ts diff --git a/packages/shared/src/types/schema.ts b/packages/shared/src/types/schema.ts index 348e9930..7996789f 100644 --- a/packages/shared/src/types/schema.ts +++ b/packages/shared/src/types/schema.ts @@ -66,6 +66,7 @@ export interface Database { profiles: { Row: { date_subscribed: string + edit_with_own_key: boolean first_name: string | null id: string last_name: string | null @@ -75,6 +76,7 @@ export interface Database { } Insert: { date_subscribed?: string + edit_with_own_key?: boolean first_name?: string | null id: string last_name?: string | null @@ -84,6 +86,7 @@ export interface Database { } Update: { date_subscribed?: string + edit_with_own_key?: boolean first_name?: string | null id?: string last_name?: string | null @@ -156,6 +159,7 @@ export interface Database { Row: { date_subscribed: string | null decrypted_open_ai_key: string | null + edit_with_own_key: boolean | null first_name: string | null id: string | null last_name: string | null @@ -166,6 +170,7 @@ export interface Database { Insert: { date_subscribed?: string | null decrypted_open_ai_key?: never + edit_with_own_key?: boolean | null first_name?: string | null id?: string | null last_name?: string | null @@ -176,6 +181,7 @@ export interface Database { Update: { date_subscribed?: string | null decrypted_open_ai_key?: never + edit_with_own_key?: boolean | null first_name?: string | null id?: string | null last_name?: string | null diff --git a/packages/supabase/functions/openai/index.ts b/packages/supabase/functions/openai/index.ts index 45d41362..f368a048 100644 --- a/packages/supabase/functions/openai/index.ts +++ b/packages/supabase/functions/openai/index.ts @@ -41,7 +41,12 @@ serve(async (req) => { let openAiApiKey = ''; try { - openAiApiKey = await getOpenAiKey(supabase, user); + if (user.user_metadata.edit_with_api_key) { + openAiApiKey = await getOpenAiKey(supabase, user); + } else { + // user can opt out of storing their api key for their editor and use the app's api key instead + openAiApiKey = Deno.env.get('OPEN_AI_KEY') ?? ''; + } } catch (err) { return new Response(JSON.stringify(err), { status: 500, diff --git a/packages/web/src/components/ApiKeySettings.tsx b/packages/web/src/components/ApiKeySettings.tsx new file mode 100644 index 00000000..d8015071 --- /dev/null +++ b/packages/web/src/components/ApiKeySettings.tsx @@ -0,0 +1,228 @@ +import { Dialog, Switch, Transition } from '@headlessui/react'; +import { Fragment, useEffect, useState } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { ReactComponent as Loading } from '../assets/loading.svg'; +import { useStoreSecret, selectorSecret } from '../store'; +import { conditionalClassNames } from '../utils/classNames'; + +export default function ApiKeySettings({ + open, + supabase, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; + supabase: any; +}) { + const { openAiKey, setOpenAiKey, session, setSession } = useStoreSecret( + selectorSecret, + shallow, + ); + const [newApiKey, setNewApiKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [helperMessage, setHelperMessage] = useState(''); + const [isEditWithMyKey, setIsEditWithMyKey] = useState(null); + + useEffect(() => { + if (session && isEditWithMyKey === null) { + setIsEditWithMyKey(!!session.user.user_metadata.is_edit_with_my_key); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + async function handleChange() { + setIsLoading(true); + + // update user's openai key + if (newApiKey === '') { + console.log('No key entered'); + } else { + await supabase.functions.invoke('insert-api-key', { + body: { + api_key: newApiKey, + }, + }); + setOpenAiKey(newApiKey); + } + + // edit here + setIsLoading(false); + setOpen(false); + } + + return ( + + { + return; + }} + > + +
+ + +
+
+ + +
+

+ Set how you want to call OpenAI API +

+
+ + { + setIsEditWithMyKey(!isEditWithMyKey); + // update user edit_with_api_key metadata. This will inform the supabase proxy to use the user's key instead of the app's key + const { data, error } = + await supabase.auth.updateUser({ + data: { + edit_with_api_key: !isEditWithMyKey, + }, + }); + + if (error) { + setHelperMessage(error.message); + return; + } + if (!data) { + setHelperMessage( + 'Your settings have not been updated. ERROR', + ); + return; + } + + if (session) { + const newSession = { ...session }; + if (newSession.user) { + newSession.user.user_metadata.edit_with_api_key = + !isEditWithMyKey; + setSession(newSession); + } + } else { + setHelperMessage( + 'Your settings have not been updated. ERROR', + ); + return; + } + }} + className={conditionalClassNames( + isEditWithMyKey + ? 'bg-green-600' + : 'bg-gray-200', + 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2', + )} + > + + + + Edit with my key + + + + {isEditWithMyKey && ( +
{ + e.preventDefault(); + await handleChange(); + }} + > + + { + const input = e.target.value; + setNewApiKey(input); + }} + placeholder="sk-************" + className="block w-full rounded-md border-0 px-2 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" + aria-describedby="username-description" + /> + {isLoading && ( + + )} + + )} +
+

+ {helperMessage} +

+
+ + +
+
+
+
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/RunFromStart.tsx b/packages/web/src/components/RunFromStart.tsx index ade4ce81..9b01051d 100644 --- a/packages/web/src/components/RunFromStart.tsx +++ b/packages/web/src/components/RunFromStart.tsx @@ -3,6 +3,7 @@ import { ChevronDoubleRightIcon } from '@heroicons/react/20/solid'; import { shallow } from 'zustand/shallow'; import { ReactComponent as Loading } from '../assets/loading.svg'; +import useSupabase from '../auth/supabaseClient'; import { useStore, useStoreSecret, selector, selectorSecret } from '../store'; import { conditionalClassNames } from '../utils/classNames'; @@ -26,10 +27,15 @@ export default function RunFromStart({ runFlow, edges, } = useStore(selector, shallow); - const { openAiKey } = useStoreSecret(selectorSecret, shallow); + const secret = useStoreSecret(selectorSecret, shallow); + const supabase = useSupabase(); + + function getSecret() { + return secret; + } async function runFromStart() { - if (openAiKey.trim() === '') { + if (getSecret().openAiKey.trim() === '') { setNotificationMessage('Please enter an OpenAI API key in the left panel.'); return; } @@ -43,7 +49,7 @@ export default function RunFromStart({ abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; setUnlockGraph(false); - await runFlow(nodes, edges, openAiKey, signal); + await runFlow(getSecret, nodes, edges, supabase, signal); setUnlockGraph(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { diff --git a/packages/web/src/nodes/templates/NodeTemplate.tsx b/packages/web/src/nodes/templates/NodeTemplate.tsx index 4012a4a7..cbaa37a0 100644 --- a/packages/web/src/nodes/templates/NodeTemplate.tsx +++ b/packages/web/src/nodes/templates/NodeTemplate.tsx @@ -14,6 +14,7 @@ import { ReactComponent as Loading } from '../../assets/loading.svg'; import FullScreenEditor from '../../components/FullScreenEditor'; import useStore, { selector } from '../../store/useStore'; import { conditionalClassNames } from '../../utils/classNames'; +import { calculateCreditsRequired } from '../../utils/userCredits'; interface NodeTemplateInterface { title: string; @@ -124,15 +125,11 @@ const NodeTemplate: FC< ].includes(type as NodeTypesEnum) } > - {(data as DefaultNodeDataType & OpenAIAPIRequest).model && ( + {(data as DefaultNodeDataType & OpenAIAPIRequest).model && data.text.length > 0 && (

- {(data as DefaultNodeDataType & OpenAIAPIRequest).model.startsWith( - 'gpt-4', - ) - ? '10' - : '1'}{' '} - credit + {'>'} + {calculateCreditsRequired(data, data.text)} credit

)} diff --git a/packages/web/src/pages/app/App.tsx b/packages/web/src/pages/app/App.tsx index d9316055..dbbcc180 100644 --- a/packages/web/src/pages/app/App.tsx +++ b/packages/web/src/pages/app/App.tsx @@ -86,7 +86,10 @@ export default function App() { setWorkflows, setChatApp, } = useStore(selector, shallow); - const { session, setSession, setOpenAiKey } = useStoreSecret(selectorSecret, shallow); + const { session, setSession, setOpenAiKey, setUserCredits } = useStoreSecret( + selectorSecret, + shallow, + ); const [settingsView, setSettingsView] = useState(true); @@ -137,6 +140,21 @@ export default function App() { supabase, ); } + const { data: credits, error: creditsError } = await supabase + .from('profiles') + .select('plan, remaining_message_credits') + .eq('id', session?.user?.id) + .single(); + + if (creditsError) { + setNotificationMessage(creditsError.message); + throw creditsError; + } + + setUserCredits({ + plan: (credits?.plan as 'free' | 'essential' | 'premium') || 'free', + credits: credits?.remaining_message_credits || 0, + }); setIsLoading(false); } if (params) { @@ -226,7 +244,6 @@ export default function App() {
-
RFStateSecret, nodes: CustomNode[], edges: Edge[], - openAiKey: string, + supabase: SupabaseClient, signal: AbortSignal, ) => Promise; clearAllNodeResponses: () => void; @@ -128,7 +132,6 @@ const useStore = create()( }; return node; }); - set({ nodes: newNodes, }); @@ -311,12 +314,13 @@ const useStore = create()( }); }, runFlow: async ( + getSecret: () => RFStateSecret, nodes: CustomNode[], edges: Edge[], - openAiKey: string, + supabase: SupabaseClient, signal: AbortSignal, ): Promise => { - await runFlow(get, nodes, edges, openAiKey); + await runFlow(get, getSecret, nodes, edges, supabase); }, clearAllNodeResponses: () => { const nodes = get().nodes; diff --git a/packages/web/src/store/useStoreSecret.ts b/packages/web/src/store/useStoreSecret.ts index 29ae1d69..04824d47 100644 --- a/packages/web/src/store/useStoreSecret.ts +++ b/packages/web/src/store/useStoreSecret.ts @@ -9,11 +9,17 @@ export type UseSecretStoreSetType = ( replace?: boolean | undefined, ) => void; +export type UserCreditsType = { + credits: number; + plan: 'free' | 'essential' | 'premium'; +}; export interface RFStateSecret { session: Session | null; setSession: (session: Session | null) => void; openAiKey: string; setOpenAiKey: (key: string) => Promise; + userCredits: UserCreditsType; + setUserCredits: (userCredits: UserCreditsType) => void; } const useStoreSecret = create()((set, get) => ({ @@ -35,6 +41,15 @@ const useStoreSecret = create()((set, get) => ({ openAiKey: key, }); }, + userCredits: { + credits: 0, + plan: 'free', + }, + setUserCredits: (credits: UserCreditsType) => { + set({ + userCredits: credits, + }); + }, })); export const selectorSecret = (state: RFStateSecret) => ({ diff --git a/packages/web/src/utils/runFlow.ts b/packages/web/src/utils/runFlow.ts index e15bea70..96379f09 100644 --- a/packages/web/src/utils/runFlow.ts +++ b/packages/web/src/utils/runFlow.ts @@ -6,10 +6,16 @@ import { initializeFlowState, getNodes, SearchDataType, + DefaultNodeDataType, + OpenAIAPIRequest, + Database, } from '@chatbutler/shared/src/index'; import { CustomNode, NodeTypesEnum } from '@chatbutler/shared/src/index'; +import { SupabaseClient } from '@supabase/supabase-js'; import { Edge } from 'reactflow'; +import { RFStateSecret } from 'src/store/useStoreSecret'; +import { calculateCreditsRequired, isNodeDoOpenAICall, isUserCreditsEnough } from './userCredits'; import { RFState } from '../store/useStore'; function inputTextPauser(node: CustomNode, get: () => RFState): Promise { @@ -44,11 +50,18 @@ function docsLoaderPauser(node: CustomNode, get: () => RFState): Promise export async function runFlow( get: () => RFState, + getSecret: () => RFStateSecret, nodes: CustomNode[], edges: Edge[], - openAiKey: string, + supabase: SupabaseClient, ) { const state = initializeFlowState(nodes, edges); + + const userCredits = getSecret().userCredits; + const setUserCredits = getSecret().setUserCredits; + const openAiKey = getSecret().openAiKey; + const session = getSecret().session; + while (state.stack.length > 0) { const nodeId = getNextNode(state, nodes); // nodeId is now redefined in each iteration if (nodeId !== null) { @@ -77,11 +90,22 @@ export async function runFlow( get().setNotificationMessage('Search block can only be used in existing chatbots'); return; } + + // TODO: check user's message credit + const enoughCredit = isUserCreditsEnough(node, node.data.text, userCredits); + if (session?.user.user_metadata.edit_with_api_key && !enoughCredit) { + get().setNotificationMessage( + 'You have reached your free plan limit. Please upgrade your plan to continue using Chatbot Butler.', + ); + return; + } + await runNode(state, get().currentWorkflow?.id, nodes, nodeId, openAiKey, { url: import.meta.env.VITE_SUPABASE_URL, key: import.meta.env.VITE_SUPABASE_PUBLIC_API, functionUrl: import.meta.env.VITE_SUPABASE_FUNCTION_URL, }); + get().setChatApp([...state.chatHistory]); if (node.type === NodeTypesEnum.inputText) { await inputTextPauser(node, get); @@ -94,6 +118,28 @@ export async function runFlow( state.chatHistory = [...get().chatApp]; } + if (session?.user.user_metadata.edit_with_api_key && isNodeDoOpenAICall(node)) { + const creditsUsed = calculateCreditsRequired( + node.data, + node.data.text + node.data.response, + ); + const remainingCredits = + userCredits.credits - creditsUsed > 0 ? userCredits.credits - creditsUsed : 0; + + const { data: updatedUser, error } = await supabase + .from('profiles') + .update({ remaining_message_credits: remainingCredits }); + + if (error) { + console.log(error); + } else if (updatedUser) { + setUserCredits({ + ...userCredits, + credits: remainingCredits, + }); + } + } + node.data.isLoading = false; get().setNodes([...nodes]); diff --git a/packages/web/src/utils/userCredits.ts b/packages/web/src/utils/userCredits.ts new file mode 100644 index 00000000..c8e5c192 --- /dev/null +++ b/packages/web/src/utils/userCredits.ts @@ -0,0 +1,41 @@ +import { + CustomNode, + NodeTypesEnum, + DefaultNodeDataType, + OpenAIAPIRequest, + AllDataTypes, +} from '@chatbutler/shared/src/index'; + +import { UserCreditsType } from '../store/useStoreSecret'; + +export function isNodeDoOpenAICall(node: CustomNode) { + return ( + node.type === NodeTypesEnum.llmPrompt || + node.type === NodeTypesEnum.chatPrompt || + node.type === NodeTypesEnum.classify || + node.type === NodeTypesEnum.search || + node.type === NodeTypesEnum.singleChatPrompt + ); +} + +export function calculateCreditsRequired(data: AllDataTypes, text: string) { + let creditsRequired = Math.ceil(text.length / 4000); + if ((data as DefaultNodeDataType & OpenAIAPIRequest).model.startsWith('gpt-4')) { + creditsRequired = creditsRequired * 10; + } + return creditsRequired; +} + +export function isUserCreditsEnough(node: CustomNode, text: string, userCredits: UserCreditsType) { + // TODO: check user's message credit + if (isNodeDoOpenAICall(node)) { + const creditsRequired = calculateCreditsRequired(node.data, text); + console.log('credits required', creditsRequired); + console.log('users credits', userCredits.credits); + if (userCredits.credits - creditsRequired < 0) { + return false; + } + return true; + } + return true; +} diff --git a/packages/web/src/windows/SettingsPanel/SettingsPanel.tsx b/packages/web/src/windows/SettingsPanel/SettingsPanel.tsx index 138db5a4..2bc3fbba 100644 --- a/packages/web/src/windows/SettingsPanel/SettingsPanel.tsx +++ b/packages/web/src/windows/SettingsPanel/SettingsPanel.tsx @@ -9,7 +9,7 @@ import { SingleChatPromptDataType, } from '@chatbutler/shared/src/index'; import { Switch } from '@headlessui/react'; -import { Cog6ToothIcon, BeakerIcon, AcademicCapIcon, TrashIcon } from '@heroicons/react/20/solid'; +import { Cog6ToothIcon, AcademicCapIcon, TrashIcon } from '@heroicons/react/20/solid'; import { SupabaseClient } from '@supabase/supabase-js'; import { useState } from 'react'; import { ReactFlowInstance, Node } from 'reactflow'; @@ -25,6 +25,7 @@ import TabsTemplate from './nodeSettings/TabsTemplate'; import TextTabs from './nodeSettings/textNode/tabs'; import NodesList from './NodesList'; import SandboxSettings from './SandboxSettings'; +import ApiKeySettings from '../../components/ApiKeySettings'; import { useStore, useStoreSecret, selector, selectorSecret } from '../../store'; import { conditionalClassNames } from '../../utils/classNames'; import Tutorial from '../Tutorial'; @@ -53,6 +54,7 @@ export default function LeftSidePanel({ const [openWorkflows, setOpenWorkflows] = useState(!currentWorkflow); const [openTutorials, setOpenTutorials] = useState(false); + const [openApiKeySettings, setOpenApiKeySettings] = useState(false); const [currentPage, setCurrentPage] = useState('Blocks'); @@ -92,7 +94,11 @@ export default function LeftSidePanel({ /> )} */} - +
{/*
@@ -138,28 +144,7 @@ export default function LeftSidePanel({ className="bg group flex cursor-pointer items-center justify-start px-2 py-1 text-sm font-medium text-slate-700 hover:bg-slate-100 hover:font-bold hover:text-slate-900" onClick={async () => { - const currentKey = openAiKey || ''; - const newOpenAIKey = window.prompt( - 'Enter your OpenAI Key here', - currentKey, - ); - - if (newOpenAIKey === null) { - return; - } - - if (newOpenAIKey === '') { - console.log('No key entered'); - } else { - if (session) { - await supabase.functions.invoke('insert-api-key', { - body: { - api_key: newOpenAIKey, - }, - }); - } - setOpenAiKey(newOpenAIKey); - } + setOpenApiKeySettings(true); }} >