diff --git a/package-lock.json b/package-lock.json index 0fb016a63..ec624a55b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,9 @@ "@next/third-parties": "^14.2.3", "@prisma/client": "^5.13.0", "@sanity/diff-match-patch": "^3.1.1", + "@supabase/auth-ui-react": "^0.4.7", + "@supabase/auth-ui-shared": "^0.1.8", + "@supabase/supabase-js": "^2.43.4", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "~4.36.1", "@trpc/client": "10.44.1", @@ -1785,6 +1788,121 @@ "node": ">=14.18" } }, + "node_modules/@stitches/core": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@stitches/core/-/core-1.2.8.tgz", + "integrity": "sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==" + }, + "node_modules/@supabase/auth-js": { + "version": "2.64.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.64.2.tgz", + "integrity": "sha512-s+lkHEdGiczDrzXJ1YWt2y3bxRi+qIUnXcgkpLSrId7yjBeaXBFygNjTaoZLG02KNcYwbuZ9qkEIqmj2hF7svw==", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/auth-ui-react": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@supabase/auth-ui-react/-/auth-ui-react-0.4.7.tgz", + "integrity": "sha512-Lp4FQGFh7BMX1Y/BFaUKidbryL7eskj1fl6Lby7BeHrTctbdvDbCMjVKS8wZ2rxuI8FtPS2iU900fSb70FHknQ==", + "dependencies": { + "@stitches/core": "^1.2.8", + "@supabase/auth-ui-shared": "0.1.8", + "prop-types": "^15.7.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.21.0" + } + }, + "node_modules/@supabase/auth-ui-shared": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-ui-shared/-/auth-ui-shared-0.1.8.tgz", + "integrity": "sha512-ouQ0DjKcEFg+0gZigFIEgu01V3e6riGZPzgVD0MJsCBNsMsiDT74+GgCEIElMUpTGkwSja3xLwdFRFgMNFKcjg==", + "peerDependencies": { + "@supabase/supabase-js": "^2.21.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.3.1.tgz", + "integrity": "sha512-QyzNle/rVzlOi4BbVqxLSH828VdGY1RElqGFAj+XeVypj6+PVtMlD21G8SDnsPQDtlqqTtoGRgdMlQZih5hTuw==", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.15.2.tgz", + "integrity": "sha512-9/7pUmXExvGuEK1yZhVYXPZnLEkDTwxgMQHXLrN5BwPZZm4iUCL1YEyep/Z2lIZah8d8M433mVAUEGsihUj5KQ==", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.9.5.tgz", + "integrity": "sha512-TEHlGwNGGmKPdeMtca1lFTYCedrhTAv3nZVoSjrKQ+wkMmaERuCe57zkC5KSWFzLYkb5FVHW8Hrr+PX1DDwplQ==", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.14.2" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.5.5.tgz", + "integrity": "sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.43.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.43.4.tgz", + "integrity": "sha512-/pLPaxiIsn5Vaz3s32HC6O/VNwfeddnzS0bZRpOW0AKcPuXroD8pT9G8mpiBlZfpKsMmq6k7tlhW7Sr1PAQ1lw==", + "dependencies": { + "@supabase/auth-js": "2.64.2", + "@supabase/functions-js": "2.3.1", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.15.2", + "@supabase/realtime-js": "2.9.5", + "@supabase/storage-js": "2.5.5" + } + }, "node_modules/@swc/helpers": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", @@ -1982,7 +2100,6 @@ "version": "20.12.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1998,6 +2115,11 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, + "node_modules/@types/phoenix": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.4.tgz", + "integrity": "sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==" + }, "node_modules/@types/plantuml-encoder": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@types/plantuml-encoder/-/plantuml-encoder-1.4.2.tgz", @@ -2108,6 +2230,14 @@ "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", @@ -8584,8 +8714,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unified": { "version": "11.0.4", diff --git a/package.json b/package.json index a86b13874..94ca7ac36 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "@next/third-parties": "^14.2.3", "@prisma/client": "^5.13.0", "@sanity/diff-match-patch": "^3.1.1", + "@supabase/auth-ui-react": "^0.4.7", + "@supabase/auth-ui-shared": "^0.1.8", + "@supabase/supabase-js": "^2.43.4", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "~4.36.1", "@trpc/client": "10.44.1", diff --git a/src/apps/settings-modal/SettingsModal.tsx b/src/apps/settings-modal/SettingsModal.tsx index 7ec93a831..4c00668d2 100644 --- a/src/apps/settings-modal/SettingsModal.tsx +++ b/src/apps/settings-modal/SettingsModal.tsx @@ -10,6 +10,7 @@ import { BrowseSettings } from '~/modules/browse/BrowseSettings'; import { DallESettings } from '~/modules/t2i/dalle/DallESettings'; import { ElevenlabsSettings } from '~/modules/elevenlabs/ElevenlabsSettings'; import { GoogleSearchSettings } from '~/modules/google/GoogleSearchSettings'; +import { SupabaseSyncSettings } from '~/modules/supabasesync/SupabaseSyncSettings'; import { ProdiaSettings } from '~/modules/t2i/prodia/ProdiaSettings'; import { T2ISettings } from '~/modules/t2i/T2ISettings'; @@ -206,6 +207,9 @@ export function SettingsModal(props: { } title='Google Search API' startCollapsed> + } title='Supabase Sync' startCollapsed> + + {/**/} diff --git a/src/common/state/store-chats.ts b/src/common/state/store-chats.ts index 9119beeba..9a9fabea2 100644 --- a/src/common/state/store-chats.ts +++ b/src/common/state/store-chats.ts @@ -386,12 +386,14 @@ export const useChatStore = create()(devtools( _get()._editConversation(conversationId, { autoTitle, + updated: Date.now(), }), setUserTitle: (conversationId: string, userTitle: string) => _get()._editConversation(conversationId, { userTitle, + updated: Date.now(), }), }), diff --git a/src/modules/backend/backend.router.ts b/src/modules/backend/backend.router.ts index 65759a1cc..762fceeba 100644 --- a/src/modules/backend/backend.router.ts +++ b/src/modules/backend/backend.router.ts @@ -61,6 +61,7 @@ export const backendRouter = createTRPCRouter({ hasLlmPerplexity: !!env.PERPLEXITY_API_KEY, hasLlmTogetherAI: !!env.TOGETHERAI_API_KEY, hasVoiceElevenLabs: !!env.ELEVENLABS_API_KEY, + hasSupabaseSync: !!env.SUPABASE_SYNC_URL && !!env.SUPABASE_SYNC_KEY, llmConfigHash: generateLlmEnvConfigHash(env), }; }), diff --git a/src/modules/backend/store-backend-capabilities.ts b/src/modules/backend/store-backend-capabilities.ts index 2824eb7ff..3436a400c 100644 --- a/src/modules/backend/store-backend-capabilities.ts +++ b/src/modules/backend/store-backend-capabilities.ts @@ -25,6 +25,7 @@ export interface BackendCapabilities { hasLlmPerplexity: boolean; hasLlmTogetherAI: boolean; hasVoiceElevenLabs: boolean; + hasSupabaseSync: boolean; llmConfigHash: string; } @@ -55,6 +56,7 @@ const useBackendCapabilitiesStore = create()( hasLlmPerplexity: false, hasLlmTogetherAI: false, hasVoiceElevenLabs: false, + hasSupabaseSync: false, llmConfigHash: '', loadedCapabilities: false, diff --git a/src/modules/supabasesync/README.md b/src/modules/supabasesync/README.md new file mode 100644 index 000000000..b28d5c5fb --- /dev/null +++ b/src/modules/supabasesync/README.md @@ -0,0 +1,55 @@ +# Supabase Sync - Experimental + +## Module Goal + +> To sync all conversations from big-agi's localDb to a server and back allowing use on multiple devices while preserving big-agi's private and local approach. + +Supabase supports multi user authentication so this module assumes you will have users setup and users can save their own data/chats to this database and they will not be accessable by other users (e.g. not used for team conversation sharing, if that is desired then get all team members to use same user account) + +## Module Status + +**Whats working:** + +- Sync "Chat Conversations" to Supabase + +**Planned:** + +- Sync Conversation folders +- Sync other shared user settings like theme, what the "Enter" key does etc + +## Supabase Setup + +- Supabase project setup (free account is fine), you will need your url & anon-key +- Table called `user_conversation` with the following schema +- Row Level Security (RLS) turned on for this table and policies setup +- One or more supabase user accounts with access to the `user_conversation` table + +```sql + +create table user_conversation ( + id uuid not null, + "systemPurposeId" character varying(255) null, + "folderId" uuid null, + created bigint not null, + updated bigint not null, + "userTitle" character varying(255) null, + "autoTitle" character varying(255) null, + messages json null, + user_id uuid null default auth.uid (), + constraint user_conversation_pkey primary key (id) + ); + +create policy "Users can mange their own data" +on "public"."user_conversation" +to public +using ( + (auth.uid() = user_id) +); + +``` + + ## Big-Agi Setup + + Navigate to your hosted instance and set your Supabase URL & KEY under the `Preferences -> Tools -> Supabase Sync` then login with your supabase user + + NOTE: the `Last Synced` is a way of tracking what chnages you need to get. To do a full sync (possibly loosing any un-synced data) reset this value to 0. diff --git a/src/modules/supabasesync/SupabaseSyncSettings.tsx b/src/modules/supabasesync/SupabaseSyncSettings.tsx new file mode 100644 index 000000000..46b7c105c --- /dev/null +++ b/src/modules/supabasesync/SupabaseSyncSettings.tsx @@ -0,0 +1,255 @@ +import { useContext, useEffect, useState, ChangeEvent } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { FormControl, FormHelperText, Input, Button, Typography, Box, Divider } from '@mui/joy'; +import { GoodModal } from '~/common/components/GoodModal'; +import DoneIcon from '@mui/icons-material/Done'; +import SyncIcon from '@mui/icons-material/Sync'; +import SearchIcon from '@mui/icons-material/Search'; + +import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities'; +import { AlreadySet } from '~/common/components/AlreadySet'; +import { FormInputKey } from '~/common/components/forms/FormInputKey'; +import { FormLabelStart } from '~/common/components/forms/FormLabelStart'; +import { Link } from '~/common/components/Link'; + +import { useSupabaseSyncStore } from './store-module-supabase-sync'; +import { isValidSupabaseConnection, syncAllConversations, getSupabaseClient, getSupabaseUserName, supabaseSignOut } from '~/modules/supabasesync/supabaseSync.client'; + +export function SupabaseSyncSettings() { + const [supabaseUserName, setSupabaseUserName] = useState(null); + const [loginDialogIsOpen, setLoginDialogIsOpen] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(null); + + // external state + const backendHasSupabaseSync = getBackendCapabilities().hasSupabaseSync; + const { + supabaseUrl, + setSupabaseUrl, + supabaseAnonKey: supabaseKey, + setSupabaseAnonKey, + lastSyncTime, + setLastSyncTime + } = useSupabaseSyncStore(state => ({ + supabaseUrl: state.supabaseUrl, setSupabaseUrl: state.setSupabaseUrl, + supabaseAnonKey: state.supabaseKey, setSupabaseAnonKey: state.setSupabaseKey, + lastSyncTime: state.lastSyncTime, setLastSyncTime: state.setLastSyncTime, + }), shallow); + + + // derived state + const isValidUrl = supabaseUrl ? isValidSupabaseConnection(supabaseUrl, supabaseKey) : backendHasSupabaseSync; + const isValidAnonKey = isValidUrl; + const canSync = isValidAnonKey && supabaseUserName; + const [syncAllState, setSyncAllState] = useState<'ok' | 'fail' | null>(null); + const [syncMessage, setSyncMessage] = useState(null); + + useEffect(() => { + const supaClient = getSupabaseClient(); + if (supaClient && isValidUrl && !supabaseUserName) { + getSupabaseUserName().then((name) => { + setSupabaseUserName(name); + }); + } + }, [isValidUrl, supabaseUserName]); + + // set sync time from input + const handleLastSyncTimeChange = (e: ChangeEvent) => { + // need to convert e.target.value to a number or 0 if empty or nan + let value = e.target.value ? Number(e.target.value) : 0; + if (isNaN(value)) + value = 0; + setLastSyncTime(value); + } + + const handleSyncAllConversations = async () => { + try { + if (!supabaseUserName) { + // need to sign in first, just a catch incase UI disable doesn't work + setSyncAllState('fail'); + setSyncMessage('Please Sign in first.'); + return; + } + const syncedCount = await syncAllConversations(setSyncMessage); + setSyncAllState('ok'); + } catch { + setSyncAllState('fail'); + } + } + + const handleLogin = () => { + // can only call if its valid + if (isValidAnonKey) { + setLoginDialogIsOpen(true); + } + } + + const handleCancelLogin = () => { + setLoginError(null); + setLoginDialogIsOpen(false); + } + + const handleSupaUserSignIn = async () => { + if (!email || !password) { + setLoginError('Please enter both email and password.'); + return; + } + + const supaClient = getSupabaseClient(); + if (supaClient) { + const { error } = await supaClient.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + setLoginError(error.message); + } else { + const name = await getSupabaseUserName(); + setSupabaseUserName(name); + handleCancelLogin(); + } + } + } + + const supaClient = getSupabaseClient(); + if (supaClient && isValidUrl && !supabaseUserName) { + getSupabaseUserName().then((name) => { + setSupabaseUserName(name); + }); + } + + const handleSignOut = async () => { + await supabaseSignOut(); + setSupabaseUserName(null); + } + + return <> + + Configure the Supabase Chat Sync, if you do not have a Supabase account you will need to create + one here, or you can use the Sign Up button + below. + + + } + required={!backendHasSupabaseSync} + isError={!isValidUrl && !backendHasSupabaseSync} + placeholder={backendHasSupabaseSync ? '...' : 'https://...supabase.co (or your self hosted url)'} + /> + + } + required={!backendHasSupabaseSync} + isError={!isValidAnonKey && !backendHasSupabaseSync} + placeholder={backendHasSupabaseSync ? '...' : 'SUPABASE_ANON_KEY'} + /> + + + + } + slotProps={{ input: { sx: { width: '100%' } } }} + sx={{ width: '100%' }} + /> + + + WARNING: Resetting Last Synced to 0 will force push all exiting chats to the server and will overwrite + them. + + + {supabaseUserName ? `Logged in as ${supabaseUserName}` : 'Please Sign In'} + + + + + + + {syncMessage && ( + + {syncMessage} + + )} + + {supaClient && ( + setLoginDialogIsOpen(false)} + hideBottomClose={true} + > + + + + setEmail(e.target.value)} + placeholder="Enter your email" + /> + + + + setPassword(e.target.value)} + placeholder="Enter your password" + /> + + {loginError && ( + + {loginError} + + )} + + + + + + + + )} + ; +} \ No newline at end of file diff --git a/src/modules/supabasesync/store-module-supabase-sync.ts b/src/modules/supabasesync/store-module-supabase-sync.ts new file mode 100644 index 000000000..8fb042bef --- /dev/null +++ b/src/modules/supabasesync/store-module-supabase-sync.ts @@ -0,0 +1,38 @@ +import {create} from 'zustand'; +import {persist} from 'zustand/middleware'; + +interface ModuleSupabaseSyncStore { + + // Supabase Sync Settings + + supabaseUrl: string; + setSupabaseUrl: (supabaseUrl: string) => void; + + supabaseKey: string; + setSupabaseKey: (key: string) => void; + + lastSyncTime: number; + setLastSyncTime: (lastSyncTime: number) => void; + +} + +export const useSupabaseSyncStore = create()( + persist( + (set) => ({ + + // Supabase Sync Settings + + supabaseUrl: '', + setSupabaseUrl: (supabaseUrl: string) => set({supabaseUrl: supabaseUrl}), + + supabaseKey: '', + setSupabaseKey: (key: string) => set({supabaseKey: key}), + + lastSyncTime: 0, + setLastSyncTime: (lastSyncTime: number) => set({lastSyncTime: lastSyncTime}), + + }), + { + name: 'app-module-supabase-sync', + }), +); \ No newline at end of file diff --git a/src/modules/supabasesync/supabaseSync.client.ts b/src/modules/supabasesync/supabaseSync.client.ts new file mode 100644 index 000000000..d6c104744 --- /dev/null +++ b/src/modules/supabasesync/supabaseSync.client.ts @@ -0,0 +1,270 @@ +import { createClient, SupabaseClient, Session } from '@supabase/supabase-js'; +import { useSupabaseSyncStore } from './store-module-supabase-sync'; +import { conversationToJsonV1 } from '~/modules/trade/trade.client'; +import { DConversation, DMessage, useChatStore, createDConversation } from '~/common/state/store-chats'; +import { SystemPurposeId } from '../../data'; + +type SupabaseConversation = { + id: string; + messages: DMessage[]; + systemPurposeId: SystemPurposeId; + userTitle?: string; + autoTitle?: string; + tokenCount: number; + created: number; + updated: number | null; +} + +export const isValidSupabaseConnection = (url?: string, key?: string) => !!url && !!key; + +const superbase_conversation_tablename = 'user_conversation'; // old sync it was conversation + +function logInfo(message: string) { + console.log(`[INFO]: ${message}`); +} + +function logError(message: string, error: any) { + console.error(`[ERROR]: ${message}`, error); +} + +// Singleton instance of Supabase Client, recreate when api key changes +let supabaseClientInstance: SupabaseClient | null = null; +let lastSupabaseClientKey: string | null = null; +let supabaseSession: Session | null = null; + + +// Singleton instance of Supabase Realtime, recreate when api key changes +export function getSupabaseClient(): SupabaseClient | null { + const { supabaseUrl, supabaseKey } = useSupabaseSyncStore.getState(); + + if (!isValidSupabaseConnection(supabaseUrl, supabaseKey)) { + return null; + } + + // if the url or key is not set the return null + if (supabaseClientInstance && lastSupabaseClientKey === supabaseKey) { + return supabaseClientInstance; + } else { + + // dispose of the previous instance if it exists + // if (supabaseClientInstance) { + // await supabaseClientInstance.auth.signOut(); + // supabaseClientInstance = null; + // } + + supabaseClientInstance = createClient(supabaseUrl, supabaseKey, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true + } + }); + lastSupabaseClientKey = supabaseKey; + //supabaseClientInstance.auth.getSession(); // are we logged in? + //supabaseClientInstance.auth.getUser + return supabaseClientInstance; + } +} + +export async function getSupabaseSession(): Promise { + + if (supabaseSession) { + return supabaseSession; + } + + const supaClient = getSupabaseClient(); + if (!supaClient) { + return null; + } + + // auto sign-in if we can + const { data: { session } } = await supaClient.auth.getSession(); + supabaseSession = session; + return supabaseSession; +} + +export async function supabaseSignOut(): Promise { + const supaClient = getSupabaseClient(); + if (supaClient) { + await supaClient.auth.signOut(); + } + supabaseSession = null; +} + +export async function getSupabaseUserName(): Promise { + // auto sign-in if we can + const session = await getSupabaseSession(); + return session?.user.email ?? null; +} + +export async function supabaseSignIn(email: string, password: string): Promise { + const supaClient = getSupabaseClient(); + if (!supaClient) { + throw new Error('Invalid Supabase Connection'); + } + + try { + const response = await supaClient.auth.signInWithPassword({ + email, + password, + }); + + if (response.error) { + throw response.error; + } + + supabaseSession = response.data.session; + return supabaseSession.user.email ?? ""; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('CORS')) { + throw new Error('CORS error: Ensure your application domain is allowed in Supabase settings and you\'re using HTTPS.'); + } else if (error.message.includes('NetworkError')) { + throw new Error('Network error: Check your internet connection and ensure Supabase URL is correct.'); + } + } + throw error; + } +} + +async function syncToServer(supabase: SupabaseClient, conversations: DConversation[], lastSyncTime: number): Promise { + // find all conversations that have been updated since the last sync + + // not the last time the server was synced as we may have changes that were before another client synced and those would get missed + // sync time needs to be the last time this instance synced with the server + + const updatedConversations = conversations + .filter(conversation => conversation.updated && conversation.updated > lastSyncTime && conversation.messages.length > 0) + .map(conversationToJsonV1); // this removes some of the fields we want to sync + + if (updatedConversations.length === 0) { + return 0; + } + + console.log(`Syncing ${updatedConversations.length} conversations`); + + const { data, error } = await supabase + .from(superbase_conversation_tablename) + .upsert(updatedConversations); + + if (error) { + console.error('Error syncing conversations:', error); + return 0; + } + + //console.log(`Synced ${updatedConversations.length} conversations`); + return updatedConversations.length; +} + +async function syncFromServerToClient(supabase: SupabaseClient, conversations: DConversation[], lastSyncTime: number): Promise { + console.log(`Fetching conversations from server > ${lastSyncTime}`); + + const { data, error } = await supabase + .from(superbase_conversation_tablename) + .select("*") + .gt('updated', lastSyncTime); + + if (error) { + console.error('Error fetching conversations from Server:', error); + return 0; + } + + if (data && data.length > 0) { + console.debug(`Found ${data.length} conversations from server`); + const conversationsFromServer: SupabaseConversation[] = data.map(record => ({ ...record })); + + const importConversation = useChatStore.getState().importConversation; + let importCount = 0; + conversationsFromServer.forEach(conversationFromServer => { + let conversation = conversations.find(conversation => conversation.id === conversationFromServer.id); + + if (conversation) { + // we may have just sent this to the server, in which case we don't need to update it + // is it already updated (e.g. did we just push that to the server?) + if (conversation.updated && conversation.updated >= (conversationFromServer.updated ?? 0)) { + return; // the same, don't touch + } + } else { + conversation = createDConversation(); + conversation.id = conversationFromServer.id; + conversation.created = conversationFromServer.created; + + //conversations.push(conversation); // insert the new conversation into the current working array + } + + conversation.updated = conversationFromServer.updated; + conversation.autoTitle = conversationFromServer.autoTitle; + conversation.userTitle = conversationFromServer.userTitle; + conversation.messages = conversationFromServer.messages; + + importConversation(conversation, false); + importCount++; + }); + + return importCount; + } else { + console.debug('No conversations from server'); + } + + return 0; +} + +async function isValidSupabaseDatabase(supabase: SupabaseClient | null): Promise { + if (!supabase) { + return 'Please configure Supabase and log in before Syncing'; + } + + try { + const { data, error } = await supabase + .from(superbase_conversation_tablename) + .select('*') + .limit(1); + + // could validate schema as well? + if (error) + return `Database Setup Error: ${error.message}`; + + return ''; + } catch (error) { + return `Database Setup Exception: ${error}`; + } +} + +export async function syncAllConversations(setMessage?: (message: string | null) => void): Promise { + const { lastSyncTime, setLastSyncTime } = useSupabaseSyncStore.getState(); + const conversations = useChatStore.getState().conversations; + const supabase = getSupabaseClient(); + + //const { folders, enableFolders } = useFolderStore.getState(); // ToDo: folder Sync ? + try { + const invalidDbMessage = await isValidSupabaseDatabase(supabase); + if (invalidDbMessage !== '') { + setMessage?.(invalidDbMessage); + return 0; + } + const session = await getSupabaseSession(); + if (!session) { + setMessage?.('Please log in before Syncing'); + return 0; + } + logInfo('Starting sync to server...'); + const pushedCount = await syncToServer(supabase, conversations, lastSyncTime); + logInfo('Sync to server completed.'); + + const updatedSyncTime = Date.now(); + logInfo('Starting sync from server to client...'); + const pulledCount = await syncFromServerToClient(supabase, conversations, lastSyncTime); + logInfo('Sync from server to client completed.'); + + setLastSyncTime(updatedSyncTime); + logInfo(`Sync completed. Last sync time updated to ${updatedSyncTime}.`); + setMessage?.(`Sync Successful, ${pushedCount} pushed, ${pulledCount} pulled`); + + // Return the number of conversations synced + return pushedCount + pulledCount; + } catch (error) { + logError('Error during syncAllConversations', error); + setMessage?.(`Failed to sync conversations: ${error}`); + return 0; + } +} \ No newline at end of file diff --git a/src/modules/trade/ExportChats.tsx b/src/modules/trade/ExportChats.tsx index 0388fc3b8..e39961ed8 100644 --- a/src/modules/trade/ExportChats.tsx +++ b/src/modules/trade/ExportChats.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Box, Button, Grid, Typography } from '@mui/joy'; import DoneIcon from '@mui/icons-material/Done'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import SyncIcon from '@mui/icons-material/Sync'; import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities'; @@ -13,7 +14,7 @@ import { KeyStroke } from '~/common/components/KeyStroke'; import { ChatLinkExport } from './link/ChatLinkExport'; import { PublishExport } from './publish/PublishExport'; import { downloadAllConversationsJson, downloadConversation } from './trade.client'; - +import { syncAllConversations } from '~/modules/supabasesync/supabaseSync.client'; export type ExportConfig = { dir: 'export', @@ -31,6 +32,8 @@ export function ExportChats(props: { config: ExportConfig, onClose: () => void } const [downloadedJSONState, setDownloadedJSONState] = React.useState<'ok' | 'fail' | null>(null); const [downloadedMarkdownState, setDownloadedMarkdownState] = React.useState<'ok' | 'fail' | null>(null); const [downloadedAllState, setDownloadedAllState] = React.useState<'ok' | 'fail' | null>(null); + const [syncAllState, setSyncAllState] = React.useState<'ok' | 'fail' | null>(null); + const [syncMessage, setSyncMessage] = React.useState(null); // external state const enableSharing = getBackendCapabilities().hasDB; @@ -65,6 +68,15 @@ export function ExportChats(props: { config: ExportConfig, onClose: () => void } .catch(() => setDownloadedAllState('fail')); }; + const handleSyncAllConversations = async () => { + try { + const syncedCount = await syncAllConversations(setSyncMessage); + setSyncAllState('ok'); + } catch { + setSyncAllState('fail'); + } + } + const hasConversation = !!props.config.conversationId; @@ -147,6 +159,22 @@ export function ExportChats(props: { config: ExportConfig, onClose: () => void } Download All ยท JSON + + + {syncMessage && ( + + {syncMessage} + + )} + )} diff --git a/src/server/env.mjs b/src/server/env.mjs index fdf8bf111..aa2076b2b 100644 --- a/src/server/env.mjs +++ b/src/server/env.mjs @@ -69,6 +69,12 @@ export const env = createEnv({ GOOGLE_CLOUD_API_KEY: z.string().optional(), GOOGLE_CSE_ID: z.string().optional(), + // Supabase Sync + SUPABASE_SYNC_URL: z.string().url().optional(), + SUPABASE_SYNC_KEY: z.string().optional(), + + // Browsing Service + PUPPETEER_WSS_ENDPOINT: z.string().url().optional(), // Text-To-Speech: ElevenLabs - speech.ts ELEVENLABS_API_KEY: z.string().optional(),