diff --git a/packages/supabase/chart/templates/uds-package.yaml b/packages/supabase/chart/templates/uds-package.yaml index 2f12f6029..4802f3bea 100644 --- a/packages/supabase/chart/templates/uds-package.yaml +++ b/packages/supabase/chart/templates/uds-package.yaml @@ -4,7 +4,7 @@ metadata: name: {{ .Values.leapfrogai.package.name }} spec: sso: - - name: Supabase + - name: Leapfrog AI description: Client for logging into Supabase clientId: {{ .Values.leapfrogai.sso.clientId }} redirectUris: diff --git a/packages/supabase/migrations/20240322174521_ui_sql_schema.sql b/packages/supabase/migrations/20240322174521_ui_sql_schema.sql index ce4438a5b..15abee1bf 100644 --- a/packages/supabase/migrations/20240322174521_ui_sql_schema.sql +++ b/packages/supabase/migrations/20240322174521_ui_sql_schema.sql @@ -1,149 +1,94 @@ +-- Create tables create table conversations ( - id uuid primary key DEFAULT uuid_generate_v4(), - user_id uuid references auth.users not null, - label text, - inserted_at timestamp with time zone default timezone('utc'::text, now()) not null + id uuid primary key DEFAULT uuid_generate_v4(), + user_id uuid references auth.users not null, + label text, + inserted_at timestamp with time zone default timezone('utc'::text, now()) not null ); - create table messages ( - id uuid primary key DEFAULT uuid_generate_v4(), - user_id uuid references auth.users not null, - conversation_id uuid references conversations on delete cascade not null, - role text check (role in ('system', 'user', 'assistant', 'function', 'data', 'tool')), - content text, - inserted_at timestamp with time zone default timezone('utc'::text, now()) not null + id uuid primary key DEFAULT uuid_generate_v4(), + user_id uuid references auth.users not null, + conversation_id uuid references conversations on delete cascade not null, + role text check (role in ('system', 'user', 'assistant', 'function', 'data', 'tool')), + content text, + inserted_at timestamp with time zone default timezone('utc'::text, now()) not null ); --- Create a table for public profiles + create table profiles ( - id uuid references auth.users not null primary key, - updated_at timestamp with time zone, - username text unique, - full_name text, - avatar_url text, - website text, - - constraint username_length check (char_length(username) >= 3) + id uuid references auth.users not null primary key, + updated_at timestamp with time zone, + username text unique, + full_name text, + avatar_url text, + website text, + constraint username_length check (char_length(username) >= 3) ); -alter table conversations enable row level security; - -alter table messages enable row level security; - -alter table profiles enable row level security; - --- Policies for conversations -create policy "Individuals can create conversations." on conversations for - insert with check (auth.uid() = user_id); -create policy "Individuals can view their own conversations. " on conversations for - select using (auth.uid() = user_id); -create policy "Individuals can update their own conversations." on conversations for - update using (auth.uid() = user_id); -create policy "Individuals can delete their own conversations." on conversations for - delete using (auth.uid() = user_id); - --- Policies for messages -create policy "Individuals can view their own messages." on messages for - select using (auth.uid() = user_id); -create policy "Individuals can create messages." on messages for - insert with check (auth.uid() = user_id); -create policy "Individuals can update their own messages." on messages for - update using (auth.uid() = user_id); -create policy "Individuals can delete their own messages." on messages for - delete using (auth.uid() = user_id); - --- Policies for profiles -create policy "Public profiles are viewable by everyone." on profiles - for select using (true); - -create policy "Users can insert their own profile." on profiles - for insert with check (auth.uid() = id); - -create policy "Users can update own profile." on profiles - for update using (auth.uid() = id); - --- Set up access controls for storage. --- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details. -create policy "Avatar images are publicly accessible." on storage.objects - for select using (bucket_id = 'avatars'); - -create policy "Anyone can upload an avatar." on storage.objects - for insert with check (bucket_id = 'avatars'); - -create policy "Anyone can update their own avatar." on storage.objects - for update using (auth.uid() = owner) with check (bucket_id = 'avatars'); - - --- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth. --- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details. -create function public.handle_new_user() -returns trigger as $$ -begin - insert into public.profiles (id, full_name, avatar_url) - values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); - return new; -end; -$$ language plpgsql security definer; -create trigger on_auth_user_created - after insert on auth.users - for each row execute procedure public.handle_new_user(); +create table assistants ( + id uuid primary key DEFAULT uuid_generate_v4(), + object text check (object in ('assistant')), + name varchar(255), + description varchar(512), + model varchar(255) not null, + instructions TEXT, + tools jsonb, + tool_resources jsonb, + metadata jsonb, + temperature float, + top_p float, + response_format jsonb, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); -- Set up Storage! +insert into storage.buckets +(id, name, public) +values + ('assistant_avatars', 'assistant_avatars', true); + +-- These are user profiles avatars, currently not used by app and will be removed soon insert into storage.buckets (id, name) - values ('avatars', 'avatars'); +values ('avatars', 'avatars'); +-- RLS policies alter table conversations enable row level security; - alter table messages enable row level security; - alter table profiles enable row level security; +alter table assistants enable row level security; -- Policies for conversations create policy "Individuals can create conversations." on conversations for insert with check (auth.uid() = user_id); create policy "Individuals can view their own conversations. " on conversations for - select using (auth.uid() = user_id); +select using (auth.uid() = user_id); create policy "Individuals can update their own conversations." on conversations for - update using (auth.uid() = user_id); +update using (auth.uid() = user_id); create policy "Individuals can delete their own conversations." on conversations for delete using (auth.uid() = user_id); -- Policies for messages create policy "Individuals can view their own messages." on messages for - select using (auth.uid() = user_id); +select using (auth.uid() = user_id); create policy "Individuals can create messages." on messages for insert with check (auth.uid() = user_id); create policy "Individuals can update their own messages." on messages for - update using (auth.uid() = user_id); +update using (auth.uid() = user_id); create policy "Individuals can delete their own messages." on messages for delete using (auth.uid() = user_id); -- Policies for profiles create policy "Public profiles are viewable by everyone." on profiles for select using (true); - create policy "Users can insert their own profile." on profiles for insert with check (auth.uid() = id); - create policy "Users can update own profile." on profiles for update using (auth.uid() = id); --- Set up access controls for storage. --- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details. -create policy "Avatar images are publicly accessible." on storage.objects - for select using (bucket_id = 'avatars'); - -create policy "Anyone can upload an avatar." on storage.objects - for insert with check (bucket_id = 'avatars'); - -create policy "Anyone can update their own avatar." on storage.objects - for update using (auth.uid() = owner) with check (bucket_id = 'avatars'); - - -- Policies for assistants -CREATE POLICY "Individuals can view their own assistants." ON assistants -FOR SELECT USING ((metadata ->> 'created_by') = auth.uid()::text); +create policy "Individuals can view their own assistants." ON assistants +for select using ((metadata ->> 'created_by') = auth.uid()::text); create policy "Individuals can create assistants." on assistants for insert with check ((metadata ->> 'created_by') = auth.uid()::text); create policy "Individuals can update their own assistants." on assistants for @@ -151,38 +96,29 @@ update using ((metadata ->> 'created_by') = auth.uid()::text); create policy "Individuals can delete their own assistants." on assistants for delete using ((metadata ->> 'created_by') = auth.uid()::text); +-- Policies for storage. +create policy "Avatar images are publicly accessible." on storage.objects + for select using (bucket_id = 'avatars'); +create policy "Anyone can upload an avatar." on storage.objects + for insert with check (bucket_id = 'avatars'); +create policy "Anyone can update their own avatar." on storage.objects + for update using (auth.uid() = owner) with check (bucket_id = 'avatars'); + +create policy "Anyone can upload an assistant avatar." on storage.objects + for insert with check (bucket_id = 'assistant_avatars'); -- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth. -- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details. create function public.handle_new_user() -returns trigger as $$ + returns trigger as $$ begin - insert into public.profiles (id, full_name, avatar_url) - values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); - return new; +insert into public.profiles (id, full_name, avatar_url) +values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); +return new; end; $$ language plpgsql security definer; create trigger on_auth_user_created - after insert on auth.users - for each row execute procedure public.handle_new_user(); + after insert on auth.users + for each row execute procedure public.handle_new_user(); + --- Set up Storage! -insert into storage.buckets (id, name) - values ('avatars', 'avatars'); - - -CREATE TABLE Assistants ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - object text CHECK (object in ('assistant')), - name VARCHAR(255), - description VARCHAR(512), - model VARCHAR(255) NOT NULL, - instructions TEXT, - tools jsonb, - tool_resources jsonb, - metadata jsonb, - temperature FLOAT, - top_p FLOAT, - response_format jsonb, - created_at timestamp with time zone default timezone('utc'::text, now()) not null -); diff --git a/src/leapfrogai_ui/.env.example b/src/leapfrogai_ui/.env.example index 00ccd6d57..9a87b587c 100644 --- a/src/leapfrogai_ui/.env.example +++ b/src/leapfrogai_ui/.env.example @@ -1,5 +1,5 @@ # PUBLIC DYNAMIC -PUBLIC_SUPABASE_URL=http://localhost:54321 +PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 PUBLIC_DISABLE_KEYCLOAK=false PUBLIC_MESSAGE_LENGTH_LIMIT=10000 diff --git a/src/leapfrogai_ui/package-lock.json b/src/leapfrogai_ui/package-lock.json index e5f239324..fa32efacf 100644 --- a/src/leapfrogai_ui/package-lock.json +++ b/src/leapfrogai_ui/package-lock.json @@ -8,6 +8,7 @@ "name": "leapfrogai-ui", "version": "0.0.1", "dependencies": { + "@carbon/colors": "^11.21.0", "@carbon/layout": "^11.20.0", "@carbon/themes": "^11.32.0", "@carbon/type": "^11.26.0", @@ -18,6 +19,7 @@ "@sveltejs/vite-plugin-svelte": "^3.1.0", "@testing-library/user-event": "^14.5.2", "ai": "^3.0.13", + "carbon-pictograms-svelte": "^12.10.0", "concurrently": "^8.2.2", "fuse.js": "^7.0.0", "msw": "^2.2.14", @@ -3193,6 +3195,11 @@ "integrity": "sha512-tw5jUSASVfdZ30CcynKWwQalkUIn3I2OFKvyormaoNH6bWjtXp21ESqDSuZ0RgZbxzaeBoUJaEJpsSTX5YjElA==", "dev": true }, + "node_modules/carbon-pictograms-svelte": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/carbon-pictograms-svelte/-/carbon-pictograms-svelte-12.10.0.tgz", + "integrity": "sha512-DPt6HRn7J8/vXTwIhtNq4VLfftS9BGeWxR1aJjHOzPkHBSooBy7SeUQK1BGJSIb/X5x3Plemq6TSBMlzvQdpbA==" + }, "node_modules/carbon-preprocess-svelte": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/carbon-preprocess-svelte/-/carbon-preprocess-svelte-0.11.0.tgz", @@ -4059,9 +4066,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.38.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.38.0.tgz", - "integrity": "sha512-IwwxhHzitx3dr0/xo0z4jjDlb2AAHBPKt+juMyKKGTLlKi1rZfA4qixMwnveU20/JTHyipM6keX4Vr7LZFYc9g==", + "version": "2.39.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.39.0.tgz", + "integrity": "sha512-FXktBLXsrxbA+6ZvJK2z/sQOrUKyzSg3fNWK5h0reSCjr2fjAsc9ai/s/JvSl4Hgvz3nYVtTIMwarZH5RcB7BA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", @@ -4069,13 +4076,13 @@ "debug": "^4.3.4", "eslint-compat-utils": "^0.5.0", "esutils": "^2.0.3", - "known-css-properties": "^0.30.0", + "known-css-properties": "^0.31.0", "postcss": "^8.4.38", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.0.16", "semver": "^7.6.0", - "svelte-eslint-parser": ">=0.35.0 <1.0.0" + "svelte-eslint-parser": ">=0.36.0 <1.0.0" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -5492,9 +5499,9 @@ } }, "node_modules/known-css-properties": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.30.0.tgz", - "integrity": "sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==", + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.31.0.tgz", + "integrity": "sha512-sBPIUGTNF0czz0mwGGUoKKJC8Q7On1GPbCSFPfyEsfHb2DyBG0Y4QtV+EVWpINSaiGKZblDNuF5AezxSgOhesQ==", "dev": true }, "node_modules/levn": { diff --git a/src/leapfrogai_ui/package.json b/src/leapfrogai_ui/package.json index 2834bceee..14157a696 100644 --- a/src/leapfrogai_ui/package.json +++ b/src/leapfrogai_ui/package.json @@ -60,6 +60,7 @@ }, "type": "module", "dependencies": { + "@carbon/colors": "^11.21.0", "@carbon/layout": "^11.20.0", "@carbon/themes": "^11.32.0", "@carbon/type": "^11.26.0", @@ -70,6 +71,7 @@ "@sveltejs/vite-plugin-svelte": "^3.1.0", "@testing-library/user-event": "^14.5.2", "ai": "^3.0.13", + "carbon-pictograms-svelte": "^12.10.0", "concurrently": "^8.2.2", "fuse.js": "^7.0.0", "msw": "^2.2.14", diff --git a/src/leapfrogai_ui/src/lib/components/AssistantAvatar.svelte b/src/leapfrogai_ui/src/lib/components/AssistantAvatar.svelte new file mode 100644 index 000000000..7f697b559 --- /dev/null +++ b/src/leapfrogai_ui/src/lib/components/AssistantAvatar.svelte @@ -0,0 +1,243 @@ + + +
+ + + +
+ + + + + + + + +
+ {#if tempImagePreviewUrl} +
+
+
+ {/if} + +
+
Upload image
+
Supported file types are .jpg and .png.
+ +
+ + {#if hideUploader} +
+ + +
+ {/if} + + {#if shouldValidate && (fileNotUploaded || fileTooBig)} +
+
{errorMsg}
+
+ {/if} +
+
+
+ + diff --git a/src/leapfrogai_ui/src/lib/components/AssistantTile.svelte b/src/leapfrogai_ui/src/lib/components/AssistantTile.svelte new file mode 100644 index 000000000..314ba1549 --- /dev/null +++ b/src/leapfrogai_ui/src/lib/components/AssistantTile.svelte @@ -0,0 +1,75 @@ + + +
+ + {#if assistant.metadata.avatar} +
+
+
+ {:else} + + {/if} +
{assistant.name}
+ +
+ {assistant.description && assistant.description.length > 62 + ? `${assistant.description?.slice(0, 62)}...` + : assistant.description} +
+ +
+ + diff --git a/src/leapfrogai_ui/src/lib/components/DynamicPictogram.svelte b/src/leapfrogai_ui/src/lib/components/DynamicPictogram.svelte new file mode 100644 index 000000000..6f0b0d1c4 --- /dev/null +++ b/src/leapfrogai_ui/src/lib/components/DynamicPictogram.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/leapfrogai_ui/src/lib/components/Pictograms.svelte b/src/leapfrogai_ui/src/lib/components/Pictograms.svelte new file mode 100644 index 000000000..10ff3d785 --- /dev/null +++ b/src/leapfrogai_ui/src/lib/components/Pictograms.svelte @@ -0,0 +1,110 @@ + + +
+ + + +
+ + diff --git a/src/leapfrogai_ui/src/lib/constants/iconMap.ts b/src/leapfrogai_ui/src/lib/constants/iconMap.ts new file mode 100644 index 000000000..b0152c3db --- /dev/null +++ b/src/leapfrogai_ui/src/lib/constants/iconMap.ts @@ -0,0 +1,205 @@ +import { + Agriculture, + Airplane, + AmsterdamWindmill, + Analytics, + Analyze, + AnalyzeCode, + AnalyzesData, + AnalyzingContainers, + AppDeveloper, + AppModernization, + ApplicationIntegration, + ApplicationPlatform, + ApplicationSecurity, + ArtTools_01, + AsiaAustralia, + AudioData, + AuditTrail, + AugmentedReality, + Automobile, + BabyBottle, + Badge, + Balloon, + BalloonHotAir, + Banking, + BareMetal, + BeijingMunicipal, + Bicycle, + BigData, + BirthdayCake, + BugVirusMalware, + BusinessContinuity, + CLanguage, + CPlusPlusLanguage, + CairoGizaPlateau, + CalendarEvent, + Camera, + CanadaMapleLeaf, + Capitol, + Cell, + ChartHistogram, + ChartLine, + ChartSunburst, + ChipDebit, + CloudNative_02, + Code, + College, + Compute, + ConsoleWireless, + ConstructionWorker, + Conversation, + CustomerService, + DataPrivacy, + Doctor, + DublinCastle, + Edge, + ErlenmeyerFlask, + FaceDissatisfied, + FaceNeutral, + FaceSatisfied, + FaceVeryDissatisfied, + FaceVerySatisfied, + Farmer_01, + Farmer_02, + FinancialConsultant, + GoLanguage, + Government_01, + Government_02, + Healthcare, + HighFive, + HomeProfile, + IbmRpa, + Idea, + Java, + Javascript, + Kubernetes, + LanguageTranslation, + MachineLearning_04, + Medical, + MedicalStaff, + Music, + Person_01, + Person_02, + Person_03, + Person_04, + Person_05, + Person_06, + Person_07, + Person_08, + Person_09, + Police, + QuestionAndAnswer, + RandomSamples, + Robot, + Speech, + User, + TrustedUser, + Virus, + WashingtonDcCapitol, + Weather, + WebDeveloper +} from 'carbon-pictograms-svelte'; + +export const iconMap = { + Agriculture, + Airplane, + AmsterdamWindmill, + Analytics, + Analyze, + AnalyzeCode, + AnalyzesData, + AnalyzingContainers, + AppDeveloper, + AppModernization, + ApplicationIntegration, + ApplicationPlatform, + ApplicationSecurity, + ArtTools_01, + AsiaAustralia, + AudioData, + AuditTrail, + AugmentedReality, + Automobile, + BabyBottle, + Badge, + Balloon, + BalloonHotAir, + Banking, + BareMetal, + BeijingMunicipal, + Bicycle, + BigData, + BirthdayCake, + BugVirusMalware, + BusinessContinuity, + CLanguage, + CPlusPlusLanguage, + CairoGizaPlateau, + CalendarEvent, + Camera, + CanadaMapleLeaf, + Capitol, + Cell, + ChartHistogram, + ChartLine, + ChartSunburst, + ChipDebit, + CloudNative_02, + Code, + College, + Compute, + ConsoleWireless, + ConstructionWorker, + Conversation, + CustomerService, + DataPrivacy, + default: User, + Doctor, + DublinCastle, + Edge, + ErlenmeyerFlask, + FaceDissatisfied, + FaceNeutral, + FaceSatisfied, + FaceVeryDissatisfied, + FaceVerySatisfied, + Farmer_01, + Farmer_02, + FinancialConsultant, + GoLanguage, + Government_01, + Government_02, + Healthcare, + HighFive, + HomeProfile, + IbmRpa, + Idea, + Java, + Javascript, + Kubernetes, + LanguageTranslation, + MachineLearning_04, + Medical, + MedicalStaff, + Music, + Person_01, + Person_02, + Person_03, + Person_04, + Person_05, + Person_06, + Person_07, + Person_08, + Person_09, + Police, + QuestionAndAnswer, + RandomSamples, + Robot, + Speech, + TrustedUser, + Virus, + WashingtonDcCapitol, + Weather, + WebDeveloper +}; diff --git a/src/leapfrogai_ui/src/lib/constants/index.ts b/src/leapfrogai_ui/src/lib/constants/index.ts index 5f2e55035..d3cce7a8f 100644 --- a/src/leapfrogai_ui/src/lib/constants/index.ts +++ b/src/leapfrogai_ui/src/lib/constants/index.ts @@ -1,5 +1,6 @@ export const MAX_LABEL_SIZE = 100; export const DEFAULT_ASSISTANT_TEMP = 0.2; +export const MAX_AVATAR_SIZE = 5000000; // PER OPENAI SPEC export const ASSISTANTS_NAME_MAX_LENGTH = 256; @@ -27,3 +28,6 @@ export const assistantDefaults: Omit = { temperature: 0.2, response_format: 'auto' }; + +export const NO_FILE_ERROR_TEXT = 'Please upload an image or select a pictogram'; +export const AVATAR_FILE_SIZE_ERROR_TEXT = `File must be less than ${MAX_AVATAR_SIZE / 1000000} MB`; diff --git a/src/leapfrogai_ui/src/lib/mocks/assistant-mocks.ts b/src/leapfrogai_ui/src/lib/mocks/assistant-mocks.ts deleted file mode 100644 index 02d2c557f..000000000 --- a/src/leapfrogai_ui/src/lib/mocks/assistant-mocks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { server } from '../../../vitest-setup'; -import { http, HttpResponse } from 'msw'; -import { faker } from '@faker-js/faker'; -import { assistantDefaults } from '$lib/constants'; - -export const mockAssistantCreation = (expectedRequestData: NewAssistantInput) => { - server.use( - http.post('/api/assistants/new', async ({ request }) => { - const requestData = await request.json(); - - // Test request data received in the request body matches the expected format - if (JSON.stringify(requestData) !== JSON.stringify(expectedRequestData)) - return HttpResponse.json({ success: false }, { status: 500 }); - - const fakeAssistant: Assistant = { - ...assistantDefaults, - ...expectedRequestData, - metadata: { - ...assistantDefaults.metadata, - ...expectedRequestData.metadata, - created_by: faker.string.uuid() - }, - id: faker.string.uuid(), - created_at: Date.now() - }; - return HttpResponse.json({ assistant: fakeAssistant }); - }) - ); -}; - -export const mockNewAssistantError = () => { - server.use(http.post('/api/assistants/new', () => new HttpResponse(null, { status: 500 }))); -}; diff --git a/src/leapfrogai_ui/src/lib/stores/assistants.ts b/src/leapfrogai_ui/src/lib/stores/assistants.ts index 6db41abd7..6622e0bba 100644 --- a/src/leapfrogai_ui/src/lib/stores/assistants.ts +++ b/src/leapfrogai_ui/src/lib/stores/assistants.ts @@ -1,6 +1,4 @@ import { writable } from 'svelte/store'; -import { toastStore } from '$stores'; -import { error } from '@sveltejs/kit'; type AssistantsStore = { assistants: Assistant[]; @@ -10,20 +8,6 @@ const defaultValues: AssistantsStore = { assistants: [] }; -const createAssistant = async (input: NewAssistantInput) => { - const res = await fetch('/api/assistants/new', { - method: 'POST', - body: JSON.stringify(input), - headers: { - 'Content-Type': 'application/json' - } - }); - - if (res.ok) return res.json(); - - return error(500, 'Error creating assistant'); -}; - const createAssistantsStore = () => { const { subscribe, set, update } = writable({ ...defaultValues }); return { @@ -34,22 +18,11 @@ const createAssistantsStore = () => { update((old) => ({ ...old, assistants })); }, getAssistants: () => {}, - createAssistant: async (assistant: NewAssistantInput) => { - const newAssistant = await createAssistant(assistant); - - if (newAssistant) { - update((old) => { - return { - ...old, - assistants: [...old.assistants, newAssistant] - }; - }); - } - toastStore.addToast({ - kind: 'success', - title: 'Assistant Created.', - subtitle: '' - }); + addAssistant: (newAssistant: Assistant) => { + update((old) => ({ + ...old, + assistants: [...old.assistants, newAssistant] + })); } }; }; diff --git a/src/leapfrogai_ui/src/lib/types/assistants.d.ts b/src/leapfrogai_ui/src/lib/types/assistants.d.ts index 4a426d493..cb523fd3c 100644 --- a/src/leapfrogai_ui/src/lib/types/assistants.d.ts +++ b/src/leapfrogai_ui/src/lib/types/assistants.d.ts @@ -3,9 +3,9 @@ type NewAssistantInput = { description: string; instructions: string; temperature: number; - metadata: { - data_sources?: string[]; - }; + data_sources?: string; + avatar?: File | null; + pictogram?: string; }; type ToolResources = { @@ -26,7 +26,9 @@ type Assistant = { tool_resources: ToolResources | null; metadata: { created_by: string | null; //user id - data_sources?: string[]; // vector store ids + data_sources?: string; // vector store ids, array as string + avatar?: string; + pictogram?: string; [key: string]: unknown; }; temperature: number | null; diff --git a/src/leapfrogai_ui/src/routes/api/assistants/+server.ts b/src/leapfrogai_ui/src/routes/api/assistants/+server.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/leapfrogai_ui/src/routes/api/assistants/new/+server.ts b/src/leapfrogai_ui/src/routes/api/assistants/new/+server.ts deleted file mode 100644 index 4d46f90b1..000000000 --- a/src/leapfrogai_ui/src/routes/api/assistants/new/+server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import { supabaseAssistantInputSchema } from '../../../../schemas/assistants'; -import { assistantDefaults } from '$lib/constants'; -import { env } from '$env/dynamic/private'; - -export async function POST({ request, locals: { supabase, getSession } }) { - const session = await getSession(); - if (!session) { - error(401, 'Unauthorized'); - } - - let requestData: NewAssistantInput; - - // Validate request body - try { - requestData = await request.json(); - const isValid = await supabaseAssistantInputSchema.isValid(requestData); - if (!isValid) error(400, 'Bad Request'); - } catch { - error(400, 'Bad Request'); - } - - const assistant: Omit = { - ...assistantDefaults, - ...requestData, - model: env.DEFAULT_MODEL, - metadata: { - ...assistantDefaults.metadata, - ...requestData.metadata, - created_by: session.user.id - } - }; - - const { error: responseError, data: createdAssistant } = await supabase - .from('assistants') - .insert(assistant) - .select() - .returns(); - - if (responseError) { - console.log( - `error creating assistant, error status: ${responseError.code}: ${responseError.message}` - ); - error(500, { message: 'Internal Server Error' }); - } - - return json(createdAssistant[0]); -} diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/+page.svelte b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/+page.svelte index e0aa8d5b8..6e984149e 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/+page.svelte +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/+page.svelte @@ -1,11 +1,11 @@ + +
{ + isSubmitting = true; + return async ({ result }) => { + isSubmitting = false; + await applyAction(result); + if (result.type === 'redirect') { + toastStore.addToast({ + kind: 'success', + title: 'Assistant Created.', + subtitle: '' + }); + await goto(result.location); + } else if (result.type === 'failure') { toastStore.addToast({ kind: 'error', - title: 'Error', - subtitle: `Error creating assistant.` + title: `Error Creating Assistant: ${result.data?.message}`, + subtitle: '' }); } - } - }); - - const handleSliderChange = () => { - /* We can't use svelte-forms-lib handleChange here because if the user clicks on the slider track instead - of clicking and dragging the handle, svelte-forms-lib is adding an extra key called "undefined" to the form - values which prevents submission - */ - updateField('temperature', $form.temperature); - }; - - - + }; + }} +>
New Assistant
- - +
- {#if $errors.name} - {$errors.name} + + {#if form?.errors?.name} + {form?.errors?.name} {/if} - {#if $errors.description} - {$errors.description} + {#if form?.errors?.description} + {form?.errors?.description} {/if} - {#if $errors.instructions} - {$errors.instructions} + {#if form?.errors?.instructions} + {form?.errors?.instructions} {/if} - {#if $errors.temperature} - {$errors.temperature} + {#if form?.errors?.temperature} + {form?.errors?.temperature} {/if}
- +
- +
@@ -193,18 +185,6 @@ @include type.type-style('heading-05'); } - .user-icon :global(svg) { - width: 3rem; - height: 3rem; - padding: layout.$spacing-03; - border-radius: 50%; - background-color: themes.$layer-01; - transition: background-color 70ms ease; - &:hover { - background-color: themes.$layer-02; - } - } - .error { color: themes.$text-error; } diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/new_assistant.test.ts b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/new_assistant.test.ts index 6cadc3982..59a361184 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/new_assistant.test.ts +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/new_assistant.test.ts @@ -1,11 +1,9 @@ import { render, screen } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; -import { mockAssistantCreation, mockNewAssistantError } from '$lib/mocks/assistant-mocks'; -import { getFakeNewAssistantInput } from '../../../../../../testUtils/fakeData'; import NewAssistantPageWithToast from './NewAssistantPageWithToast.test.svelte'; import { afterAll, beforeAll, type MockInstance, vi } from 'vitest'; import * as navigation from '$app/navigation'; -import { ASSISTANTS_NAME_MAX_LENGTH, ASSISTANTS_DESCRIPTION_MAX_LENGTH } from '$lib/constants'; +import { ASSISTANTS_DESCRIPTION_MAX_LENGTH, ASSISTANTS_NAME_MAX_LENGTH } from '$lib/constants'; describe('New Assistant page', () => { let goToSpy: MockInstance; @@ -13,79 +11,18 @@ describe('New Assistant page', () => { beforeAll(() => { goToSpy = vi.spyOn(navigation, 'goto'); }); + afterEach(() => { vi.clearAllMocks(); }); afterAll(() => { vi.restoreAllMocks(); }); - it('creates a new assistant', async () => { - const newAssistantInput = getFakeNewAssistantInput(); - - mockAssistantCreation(newAssistantInput); - render(NewAssistantPageWithToast); - - const nameField = screen.getByRole('textbox', { name: /name/i }); - const taglineField = screen.getByRole('textbox', { name: /description/i }); - const instructionsField = screen.getByPlaceholderText(/you'll act as\.\.\./i); - const saveBtn = screen.getByRole('button', { name: /save/i }); - - await userEvent.type(nameField, newAssistantInput.name); - await userEvent.type(taglineField, newAssistantInput.description); - await userEvent.type(instructionsField, newAssistantInput.instructions); - - // Note - unknown how to change slider value so leaving at default - - await userEvent.click(saveBtn); - await screen.findByText('Assistant Created.'); - }); - it('displays an error toast when there is an error creating an assistant', async () => { - const newAssistantInput = getFakeNewAssistantInput(); - - mockNewAssistantError(); - render(NewAssistantPageWithToast); - - const nameField = screen.getByRole('textbox', { name: /name/i }); - const taglineField = screen.getByRole('textbox', { name: /description/i }); - const instructionsField = screen.getByPlaceholderText(/you'll act as\.\.\./i); - const saveBtn = screen.getByRole('button', { name: /save/i }); - - await userEvent.type(nameField, newAssistantInput.name); - await userEvent.type(taglineField, newAssistantInput.description); - await userEvent.type(instructionsField, newAssistantInput.instructions); - - await userEvent.click(saveBtn); - await screen.findByText('Error creating assistant.'); - }); - it('validates required fields', async () => { - const newAssistantInput = getFakeNewAssistantInput(); - - mockAssistantCreation(newAssistantInput); - render(NewAssistantPageWithToast); - const nameField = screen.getByRole('textbox', { name: /name/i }); - const taglineField = screen.getByRole('textbox', { name: /description/i }); - const instructionsField = screen.getByPlaceholderText(/you'll act as\.\.\./i); - const saveBtn = screen.getByRole('button', { name: /save/i }); - - await userEvent.type(nameField, newAssistantInput.name); - - await userEvent.click(saveBtn); - - expect(saveBtn).toHaveProperty('disabled', true); - const requiredWarnings = screen.getAllByText(/Required/i); - expect(requiredWarnings).toHaveLength(2); - - // Fill out remaining fields, ensure submit button remains disabled until form is valid - await userEvent.type(taglineField, newAssistantInput.description); - expect(saveBtn).toHaveProperty('disabled', true); - await userEvent.type(instructionsField, newAssistantInput.instructions); - expect(saveBtn).toHaveProperty('disabled', false); - }); it('has a modal that navigates back to the management page', async () => { render(NewAssistantPageWithToast); - const cancelBtn = screen.getByRole('button', { name: /cancel/i }); + const cancelBtn = screen.getAllByRole('button', { name: /cancel/i })[0]; await userEvent.click(cancelBtn); await userEvent.click(screen.getByText('Leave this page')); @@ -95,7 +32,7 @@ describe('New Assistant page', () => { it('has a modal that stays on page when canceled', async () => { render(NewAssistantPageWithToast); - const cancelBtn = screen.getByRole('button', { name: /cancel/i }); + const cancelBtn = screen.getAllByRole('button', { name: /cancel/i })[0]; await userEvent.click(cancelBtn); await userEvent.click(screen.getByText('Stay on page')); diff --git a/src/leapfrogai_ui/src/schemas/assistants.ts b/src/leapfrogai_ui/src/schemas/assistants.ts index 6cddcf55c..05afc7a0b 100644 --- a/src/leapfrogai_ui/src/schemas/assistants.ts +++ b/src/leapfrogai_ui/src/schemas/assistants.ts @@ -1,4 +1,4 @@ -import { array, number, object, ObjectSchema, string } from 'yup'; +import { mixed, number, object, ObjectSchema, string } from 'yup'; import { ASSISTANTS_INSTRUCTIONS_MAX_LENGTH, ASSISTANTS_NAME_MAX_LENGTH } from '$lib/constants'; export const supabaseAssistantInputSchema: ObjectSchema = object({ @@ -6,9 +6,9 @@ export const supabaseAssistantInputSchema: ObjectSchema = obj description: string().max(ASSISTANTS_NAME_MAX_LENGTH).required('Required'), instructions: string().max(ASSISTANTS_INSTRUCTIONS_MAX_LENGTH).required('Required'), temperature: number().required('Required'), - metadata: object({ - data_sources: array().of(string().required('Required')) - }) + data_sources: string(), + avatar: mixed().nullable(), // additional validation for avatar and pictogram is handled in Modal component + pictogram: string() }) .noUnknown(true) .strict(); diff --git a/src/leapfrogai_ui/supabase/migrations/20240322174519_create_conversations_table.sql b/src/leapfrogai_ui/supabase/migrations/20240322174519_create_conversations_table.sql deleted file mode 100644 index 0f6d7d9f8..000000000 --- a/src/leapfrogai_ui/supabase/migrations/20240322174519_create_conversations_table.sql +++ /dev/null @@ -1,6 +0,0 @@ -create table conversations ( - id uuid primary key DEFAULT uuid_generate_v4(), - user_id uuid references auth.users not null, - label text, - inserted_at timestamp with time zone default timezone('utc'::text, now()) not null -); \ No newline at end of file diff --git a/src/leapfrogai_ui/supabase/migrations/20240322174700_create_messages_table.sql b/src/leapfrogai_ui/supabase/migrations/20240322174700_create_messages_table.sql deleted file mode 100644 index 553945fb1..000000000 --- a/src/leapfrogai_ui/supabase/migrations/20240322174700_create_messages_table.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table messages ( - id uuid primary key DEFAULT uuid_generate_v4(), - user_id uuid references auth.users not null, - conversation_id uuid references conversations on delete cascade not null, - role text check (role in ('system', 'user', 'assistant', 'function', 'data', 'tool')), - content text, - inserted_at timestamp with time zone default timezone('utc'::text, now()) not null -); \ No newline at end of file diff --git a/src/leapfrogai_ui/supabase/migrations/20240322174734_create_profiles_table.sql b/src/leapfrogai_ui/supabase/migrations/20240322174734_create_profiles_table.sql deleted file mode 100644 index 17861b70a..000000000 --- a/src/leapfrogai_ui/supabase/migrations/20240322174734_create_profiles_table.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Create a table for public profiles -create table profiles ( - id uuid references auth.users not null primary key, - updated_at timestamp with time zone, - username text unique, - full_name text, - avatar_url text, - website text, - - constraint username_length check (char_length(username) >= 3) -); \ No newline at end of file diff --git a/src/leapfrogai_ui/supabase/migrations/20240322174754_create_role_level_security.sql b/src/leapfrogai_ui/supabase/migrations/20240322174754_create_role_level_security.sql deleted file mode 100644 index 2cb13f884..000000000 --- a/src/leapfrogai_ui/supabase/migrations/20240322174754_create_role_level_security.sql +++ /dev/null @@ -1,57 +0,0 @@ -alter table conversations enable row level security; - -alter table messages enable row level security; - -alter table profiles enable row level security; - --- Policies for conversations -create policy "Individuals can create conversations." on conversations for - insert with check (auth.uid() = user_id); -create policy "Individuals can view their own conversations. " on conversations for - select using (auth.uid() = user_id); -create policy "Individuals can update their own conversations." on conversations for - update using (auth.uid() = user_id); -create policy "Individuals can delete their own conversations." on conversations for - delete using (auth.uid() = user_id); - --- Policies for messages -create policy "Individuals can view their own messages." on messages for - select using (auth.uid() = user_id); -create policy "Individuals can create messages." on messages for - insert with check (auth.uid() = user_id); -create policy "Individuals can update their own messages." on messages for - update using (auth.uid() = user_id); -create policy "Individuals can delete their own messages." on messages for - delete using (auth.uid() = user_id); - --- Policies for profiles -create policy "Public profiles are viewable by everyone." on profiles - for select using (true); - -create policy "Users can insert their own profile." on profiles - for insert with check (auth.uid() = id); - -create policy "Users can update own profile." on profiles - for update using (auth.uid() = id); - --- Set up access controls for storage. --- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details. -create policy "Avatar images are publicly accessible." on storage.objects - for select using (bucket_id = 'avatars'); - -create policy "Anyone can upload an avatar." on storage.objects - for insert with check (bucket_id = 'avatars'); - -create policy "Anyone can update their own avatar." on storage.objects - for update using (auth.uid() = owner) with check (bucket_id = 'avatars'); - - --- Policies for assistants -CREATE POLICY "Individuals can view their own assistants." ON assistants -FOR SELECT USING ((metadata ->> 'created_by') = auth.uid()::text); -create policy "Individuals can create assistants." on assistants for - insert with check ((metadata ->> 'created_by') = auth.uid()::text); -create policy "Individuals can update their own assistants." on assistants for -update using ((metadata ->> 'created_by') = auth.uid()::text); -create policy "Individuals can delete their own assistants." on assistants for - delete using ((metadata ->> 'created_by') = auth.uid()::text); \ No newline at end of file diff --git a/src/leapfrogai_ui/supabase/migrations/20240322174913_create_function_new_user.sql b/src/leapfrogai_ui/supabase/migrations/20240322174913_create_function_new_user.sql deleted file mode 100644 index b0379b3d5..000000000 --- a/src/leapfrogai_ui/supabase/migrations/20240322174913_create_function_new_user.sql +++ /dev/null @@ -1,17 +0,0 @@ --- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth. --- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details. -create function public.handle_new_user() -returns trigger as $$ -begin - insert into public.profiles (id, full_name, avatar_url) - values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); - return new; -end; -$$ language plpgsql security definer; -create trigger on_auth_user_created - after insert on auth.users - for each row execute procedure public.handle_new_user(); - --- Set up Storage! -insert into storage.buckets (id, name) - values ('avatars', 'avatars'); \ No newline at end of file diff --git a/src/leapfrogai_ui/supabase/migrations/20240502181232_assistants.sql b/src/leapfrogai_ui/supabase/migrations/20240502181232_assistants.sql deleted file mode 100644 index c16363d80..000000000 --- a/src/leapfrogai_ui/supabase/migrations/20240502181232_assistants.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE Assistants ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - object text CHECK (object in ('assistant')), - name VARCHAR(255), - description VARCHAR(512), - model VARCHAR(255) NOT NULL, - instructions TEXT, - tools jsonb, - tool_resources jsonb, - metadata jsonb, - temperature FLOAT, - top_p FLOAT, - response_format jsonb, - created_at timestamp with time zone default timezone('utc'::text, now()) not null -); diff --git a/src/leapfrogai_ui/supabase/migrations/20240513155910_all_migrations.sql b/src/leapfrogai_ui/supabase/migrations/20240513155910_all_migrations.sql new file mode 100644 index 000000000..0e4643c10 --- /dev/null +++ b/src/leapfrogai_ui/supabase/migrations/20240513155910_all_migrations.sql @@ -0,0 +1,124 @@ +-- Create tables +create table conversations ( + id uuid primary key DEFAULT uuid_generate_v4(), + user_id uuid references auth.users not null, + label text, + inserted_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +create table messages ( + id uuid primary key DEFAULT uuid_generate_v4(), + user_id uuid references auth.users not null, + conversation_id uuid references conversations on delete cascade not null, + role text check (role in ('system', 'user', 'assistant', 'function', 'data', 'tool')), + content text, + inserted_at timestamp with time zone default timezone('utc'::text, now()) not null +); + + +create table profiles ( + id uuid references auth.users not null primary key, + updated_at timestamp with time zone, + username text unique, + full_name text, + avatar_url text, + website text, + constraint username_length check (char_length(username) >= 3) +); + +create table assistants ( + id uuid primary key DEFAULT uuid_generate_v4(), + object text check (object in ('assistant')), + name varchar(255), + description varchar(512), + model varchar(255) not null, + instructions TEXT, + tools jsonb, + tool_resources jsonb, + metadata jsonb, + temperature float, + top_p float, + response_format jsonb, +created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Set up Storage! +insert into storage.buckets +(id, name, public) +values + ('assistant_avatars', 'assistant_avatars', true); + +-- These are user profiles avatars, currently not used by app and will be removed soon +insert into storage.buckets (id, name) +values ('avatars', 'avatars'); + +-- RLS policies +alter table conversations enable row level security; +alter table messages enable row level security; +alter table profiles enable row level security; +alter table assistants enable row level security; + +-- Policies for conversations +create policy "Individuals can create conversations." on conversations for + insert with check (auth.uid() = user_id); +create policy "Individuals can view their own conversations. " on conversations for +select using (auth.uid() = user_id); +create policy "Individuals can update their own conversations." on conversations for +update using (auth.uid() = user_id); +create policy "Individuals can delete their own conversations." on conversations for + delete using (auth.uid() = user_id); + +-- Policies for messages +create policy "Individuals can view their own messages." on messages for +select using (auth.uid() = user_id); +create policy "Individuals can create messages." on messages for + insert with check (auth.uid() = user_id); +create policy "Individuals can update their own messages." on messages for +update using (auth.uid() = user_id); +create policy "Individuals can delete their own messages." on messages for + delete using (auth.uid() = user_id); + +-- Policies for profiles +create policy "Public profiles are viewable by everyone." on profiles + for select using (true); +create policy "Users can insert their own profile." on profiles + for insert with check (auth.uid() = id); +create policy "Users can update own profile." on profiles + for update using (auth.uid() = id); + +-- Policies for assistants +create policy "Individuals can view their own assistants." ON assistants +for select using ((metadata ->> 'created_by') = auth.uid()::text); +create policy "Individuals can create assistants." on assistants for + insert with check ((metadata ->> 'created_by') = auth.uid()::text); +create policy "Individuals can update their own assistants." on assistants for +update using ((metadata ->> 'created_by') = auth.uid()::text); +create policy "Individuals can delete their own assistants." on assistants for + delete using ((metadata ->> 'created_by') = auth.uid()::text); + +-- Policies for storage. +create policy "Avatar images are publicly accessible." on storage.objects + for select using (bucket_id = 'avatars'); +create policy "Anyone can upload an avatar." on storage.objects + for insert with check (bucket_id = 'avatars'); +create policy "Anyone can update their own avatar." on storage.objects + for update using (auth.uid() = owner) with check (bucket_id = 'avatars'); + +create policy "Anyone can upload an assistant avatar." on storage.objects + for insert with check (bucket_id = 'assistant_avatars'); + +-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth. +-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details. +create function public.handle_new_user() + returns trigger as $$ +begin +insert into public.profiles (id, full_name, avatar_url) +values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); +return new; +end; +$$ language plpgsql security definer; +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + + diff --git a/src/leapfrogai_ui/testUtils/fakeData/index.ts b/src/leapfrogai_ui/testUtils/fakeData/index.ts index 5d48e482d..7490d1acf 100644 --- a/src/leapfrogai_ui/testUtils/fakeData/index.ts +++ b/src/leapfrogai_ui/testUtils/fakeData/index.ts @@ -99,7 +99,9 @@ export const getFakeAssistant = (): Assistant => { temperature: DEFAULT_ASSISTANT_TEMP, metadata: { created_by: faker.string.uuid(), - data_sources: [] + data_sources: '', + pictogram: 'default', + avatar: undefined }, created_at: Date.now() }; @@ -111,8 +113,8 @@ export const getFakeNewAssistantInput = (): NewAssistantInput => { description: faker.lorem.sentence(), instructions: faker.lorem.paragraph(), temperature: DEFAULT_ASSISTANT_TEMP, - metadata: { - data_sources: [] - } + data_sources: '', + pictogram: 'default', + avatar: null }; }; diff --git a/src/leapfrogai_ui/tests/assistant-avatars.test.ts b/src/leapfrogai_ui/tests/assistant-avatars.test.ts new file mode 100644 index 000000000..5188258d1 --- /dev/null +++ b/src/leapfrogai_ui/tests/assistant-avatars.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from './fixtures'; +import { getFakeNewAssistantInput } from '../testUtils/fakeData'; +import { deleteAssistantByName, uploadAvatar } from './helpers'; +import { NO_FILE_ERROR_TEXT } from '../src/lib/constants/index'; + +test('it can search for and choose a pictogram as an avatar', async ({ page }) => { + const assistantInput = getFakeNewAssistantInput(); + + const pictogramName = 'Analytics'; + + await page.goto('/chat/assistants-management/new'); + + await page.getByLabel('name').fill(assistantInput.name); + await page.getByLabel('description').fill(assistantInput.description); + await page.getByPlaceholder("You'll act as...").fill(assistantInput.instructions); + + await page.locator('.mini-avatar-container').click(); + + await page.getByPlaceholder('Search').click(); + await page.getByPlaceholder('Search').fill(pictogramName); + + await page.getByTestId(`pictogram-${pictogramName}`).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + + // Wait for modal save button to disappear + const saveButtons = page.getByRole('button', { name: 'Save' }); + await expect(saveButtons).toHaveCount(1); + + const miniAvatarContainer = page.getByTestId('mini-avatar-container'); + const pictogram = miniAvatarContainer.getByTestId(`pictogram-${pictogramName}`); + await expect(pictogram).toBeVisible(); + + // cleanup + await deleteAssistantByName(assistantInput.name); +}); + +// Note - once photo is uploaded, playwright is changing the url for the file so we cannot test the name of the image +test('it can upload an image as an avatar', async ({ page }) => { + const assistantInput = getFakeNewAssistantInput(); + + await page.goto('/chat/assistants-management/new'); + + await page.getByLabel('name').fill(assistantInput.name); + await page.getByLabel('description').fill(assistantInput.description); + await page.getByPlaceholder("You'll act as...").fill(assistantInput.instructions); + + await page.locator('.mini-avatar-container').click(); + await uploadAvatar(page); + + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + + // Wait for modal save button to disappear + const saveButtons = page.getByRole('button', { name: 'Save' }); + await expect(saveButtons).toHaveCount(1); + + await saveButtons.click(); + + // cleanup + await deleteAssistantByName(assistantInput.name); +}); + +test('it can change an image uploaded as an avatar', async ({ page }) => { + await page.goto('/chat/assistants-management/new'); + + await page.locator('.mini-avatar-container').click(); + await uploadAvatar(page); + + const imageUploadContainer = page.getByTestId('image-upload-avatar'); + const backgroundImage = await imageUploadContainer.evaluate((node) => { + return window.getComputedStyle(node).backgroundImage; + }); + + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + + await page.locator('.mini-avatar-container').click(); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByText('Change', { exact: true }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles('./tests/fixtures/frog.png'); + + const newBackgroundImage = await imageUploadContainer.evaluate((node) => { + return window.getComputedStyle(node).backgroundImage; + }); + + expect(backgroundImage).not.toEqual(newBackgroundImage); +}); + +test('it shows an error when clicking save on the upload tab if no image is uploaded', async ({ + page +}) => { + const assistantInput = getFakeNewAssistantInput(); + + await page.goto('/chat/assistants-management/new'); + + await page.getByLabel('name').fill(assistantInput.name); + await page.getByLabel('description').fill(assistantInput.description); + await page.getByPlaceholder("You'll act as...").fill(assistantInput.instructions); + + await page.locator('.mini-avatar-container').click(); + await page.getByText('Upload', { exact: true }).click(); + + const saveButton = page.getByRole('button', { name: 'Save' }).nth(0); + + await saveButton.click(); + + await expect(page.getByText(NO_FILE_ERROR_TEXT)).toBeVisible(); +}); + +// Note - not testing too large file size validation because we would have to store a large file just for a test + +test('it removes an uploaded image and keeps the original pictogram on save', async ({ page }) => { + await page.goto('/chat/assistants-management/new'); + await page.locator('.mini-avatar-container').click(); + + await uploadAvatar(page); + + await page.getByText('Remove').click(); + + await expect(page.getByTestId('image-upload-avatar')).toHaveCount(0); + + await page.getByText('Pictogram', { exact: true }).click(); + + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + + const miniAvatarContainer = page.getByTestId('mini-avatar-container'); + const pictogram = miniAvatarContainer.getByTestId(`pictogram-default`); + await expect(pictogram).toBeVisible(); +}); + +test('it keeps the original pictogram on cancel after uploading an image but not saving it', async ({ + page +}) => { + await page.goto('/chat/assistants-management/new'); + await page.locator('.mini-avatar-container').click(); + + await uploadAvatar(page); + + await page.getByText('Cancel').nth(0).click(); + + const miniAvatarContainer = page.getByTestId('mini-avatar-container'); + const pictogram = miniAvatarContainer.getByTestId(`pictogram-default`); + await expect(pictogram).toBeVisible(); +}); + +test('it keeps the original pictogram on cancel after changing the pictogram but not saving it', async ({ + page +}) => { + const pictogramName = 'Analytics'; + await page.goto('/chat/assistants-management/new'); + await page.locator('.mini-avatar-container').click(); + + await page.getByPlaceholder('Search').click(); + await page.getByPlaceholder('Search').fill(pictogramName); + + await page.getByTestId(`pictogram-${pictogramName}`).click(); + + await page.getByText('Cancel').nth(0).click(); + + const miniAvatarContainer = page.getByTestId('mini-avatar-container'); + const pictogram = miniAvatarContainer.getByTestId(`pictogram-default`); + await expect(pictogram).toBeVisible(); +}); + +// Testing close button, not cancel button. Close and Cancel should remain connected to the same helper function so only testing one edge case +test('it keeps the original pictogram on close (not cancel) after changing the pictogram but not saving it', async ({ + page +}) => { + const pictogramName = 'Analytics'; + await page.goto('/chat/assistants-management/new'); + await page.locator('.mini-avatar-container').click(); + + await page.getByPlaceholder('Search').click(); + await page.getByPlaceholder('Search').fill(pictogramName); + + await page.getByTestId(`pictogram-${pictogramName}`).click(); + + await page.getByLabel('Close the modal').nth(0).click(); + + const miniAvatarContainer = page.getByTestId('mini-avatar-container'); + const pictogram = miniAvatarContainer.getByTestId(`pictogram-default`); + await expect(pictogram).toBeVisible(); +}); + +test('it saves the pictogram if the save button is clicked on the pictogram tab even if an image was uploaded', async ({ + page +}) => { + const pictogramName = 'Analytics'; + + await page.goto('/chat/assistants-management/new'); + await page.locator('.mini-avatar-container').click(); + + await uploadAvatar(page); + + await page.getByText('Pictogram', { exact: true }).click(); + + await page.getByTestId(`pictogram-${pictogramName}`).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + + const miniAvatarContainer = page.getByTestId('mini-avatar-container'); + const pictogram = miniAvatarContainer.getByTestId(`pictogram-${pictogramName}`); + await expect(pictogram).toBeVisible(); +}); diff --git a/src/leapfrogai_ui/tests/assistants.test.ts b/src/leapfrogai_ui/tests/assistants.test.ts index 887978bd1..83e110909 100644 --- a/src/leapfrogai_ui/tests/assistants.test.ts +++ b/src/leapfrogai_ui/tests/assistants.test.ts @@ -1,6 +1,7 @@ import { expect, test } from './fixtures'; import { createAssistant, deleteAssistantByName, loadChatPage } from './helpers'; import { getFakeNewAssistantInput } from '../testUtils/fakeData'; +import type { ActionResult } from '@sveltejs/kit'; test('it navigates to the assistants page', async ({ page }) => { await loadChatPage(page); @@ -32,6 +33,30 @@ test('it creates an assistant and navigates back to the management page', async await deleteAssistantByName(assistantInput.name); }); +test('displays an error toast when there is an error creating an assistant and remains on the assistant page', async ({ + page +}) => { + const assistantInput = getFakeNewAssistantInput(); + + await page.route('*/**/chat/assistants-management/new', async (route) => { + if (route.request().method() === 'POST') { + const result: ActionResult = { + type: 'failure', + status: 500 + }; + + await route.fulfill({ json: result }); + } else { + const response = await route.fetch(); + await route.fulfill({ response }); + } + }); + + await createAssistant(page, assistantInput); + + await expect(page.getByText('Error creating assistant')).toBeVisible(); +}); + test('it can search for assistants', async ({ page }) => { const assistantInput1 = getFakeNewAssistantInput(); const assistantInput2 = getFakeNewAssistantInput(); @@ -73,3 +98,15 @@ test('it can navigate with breadcrumbs', async ({ page }) => { await page.getByRole('link', { name: 'Chat' }).click(); await page.waitForURL('/chat'); }); + +test('it validates input', async ({ page }) => { + await page.goto('/chat/assistants-management/new'); + await page.getByLabel('name').fill('my assistant'); + const saveButton = page.getByRole('button', { name: 'Save' }); + + await saveButton.click(); + + await expect(page.getByText('Required')).toHaveCount(2); + + await expect(page.getByText('Error creating assistant: Bad Request')).toBeVisible(); +}); diff --git a/src/leapfrogai_ui/tests/fixtures/Doug.png b/src/leapfrogai_ui/tests/fixtures/Doug.png new file mode 100644 index 000000000..403f8ecb5 Binary files /dev/null and b/src/leapfrogai_ui/tests/fixtures/Doug.png differ diff --git a/src/leapfrogai_ui/tests/fixtures/frog.png b/src/leapfrogai_ui/tests/fixtures/frog.png new file mode 100644 index 000000000..48db06b61 Binary files /dev/null and b/src/leapfrogai_ui/tests/fixtures/frog.png differ diff --git a/src/leapfrogai_ui/tests/helpers.ts b/src/leapfrogai_ui/tests/helpers.ts index e53661f3e..260252c1b 100644 --- a/src/leapfrogai_ui/tests/helpers.ts +++ b/src/leapfrogai_ui/tests/helpers.ts @@ -30,7 +30,11 @@ export const createAssistant = async (page: Page, assistantInput: NewAssistantIn await page.getByPlaceholder("You'll act as...").fill(assistantInput.instructions); await page.locator('.bx--slider__track').click(); - await page.getByRole('button', { name: 'Save' }).click(); + // Wait for modal save button to disappear if avatar modal was open + const saveButtons = page.getByRole('button', { name: 'Save' }); + await expect(saveButtons).toHaveCount(1); + + await saveButtons.click(); }; export const deleteConversationsByLabel = async (labels: string[]) => { @@ -46,3 +50,22 @@ export const waitForResponseToComplete = async (page: Page) => { export const deleteAssistantByName = async (name: string) => { await supabase.from('conversations').delete().eq('name', name); }; + +export const attachAvatarImage = async (page: Page, imageName: string) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.locator('label').filter({ hasText: 'Upload from computer' }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(`./tests/fixtures/${imageName}.png`); +}; + +export const uploadAvatar = async (page: Page, imageName = 'Doug') => { + await page.getByText('Upload', { exact: true }).click(); + + await attachAvatarImage(page, imageName); + + const imageUploadContainer = page.getByTestId('image-upload-avatar'); + const hasImage = await imageUploadContainer.evaluate((node) => { + return window.getComputedStyle(node).backgroundImage; + }); + expect(hasImage).toBeDefined(); +}; diff --git a/src/leapfrogai_ui/vite.config.ts b/src/leapfrogai_ui/vite.config.ts index 44f2023d0..90b90bc58 100644 --- a/src/leapfrogai_ui/vite.config.ts +++ b/src/leapfrogai_ui/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(() => ({ preprocessorOptions: { scss: { additionalData: - '@use "@carbon/themes/scss/themes" as *; @use "@carbon/themes" with ($theme: $g90); @use "@carbon/layout"; @use "@carbon/type";' + '@use "@carbon/themes/scss/themes" as *; @use "@carbon/themes" with ($theme: $g90); @use "@carbon/layout"; @use "@carbon/type"; @use "@carbon/colors" as *;' } } } diff --git a/src/leapfrogai_ui/vitest-setup.ts b/src/leapfrogai_ui/vitest-setup.ts index 980f3f0e7..b85416181 100644 --- a/src/leapfrogai_ui/vitest-setup.ts +++ b/src/leapfrogai_ui/vitest-setup.ts @@ -10,6 +10,9 @@ import * as navigation from '$app/navigation'; import * as stores from '$app/stores'; import { fakeConversations } from './testUtils/fakeData'; +// Fixes error: node.scrollIntoView is not a function +window.HTMLElement.prototype.scrollIntoView = function () {}; + vi.mock('$env/dynamic/public', () => { return { env: {