Skip to content

Commit

Permalink
278 feat(ui): cancel generation (#406)
Browse files Browse the repository at this point in the history
* feat: cancel message stream, needs tests

* chore: add tests

* chore: use submit type for cancel button

* chore: wip e2e improvements

* chore: wip e2e improvements 2

* chore: merge main

* fix: remove husky reference in readme

* chore: add ability to turn off keycloak auth

* fix: dynamic env import

* fix: add disable keycloak var to zarf yaml

* chore: version bump 0.1.8

* chore: Add to uds bundle

* chore: update folder name

* fix: remove duplicate ui from bundle, adjust default vars

* fix: use dev tag for ui uds bundle

* chore: get anonkey from secretRef

* chore: Rever to require supabase anon key instead of using secret from hosted version

* chore: default uds bundle to SAAS supabase temporarily

* chore: dev/gpu config gets SAAS supabase temporarily too

* fix: dynamic variables instead of static

* fix: update variable names for zarf

* fix: conversations id import bug

* fix: remove duplicated test

* fix: e2e tests

* fix: minor changes from pr review

* fix: minor changes from pr review 2

* chore: update vars to pull secret for sb or use provided value

* chore: wip, fix response bug when backend down

* chore: adjust tests

* chore: update e2es
  • Loading branch information
andrewrisse authored Apr 23, 2024
1 parent 76aa0d4 commit 1d3a7f8
Show file tree
Hide file tree
Showing 23 changed files with 275 additions and 100 deletions.
4 changes: 3 additions & 1 deletion src/leapfrogai_ui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ LEAPFROGAI_API_KEY=my-test-key
# PLAYWRIGHT
USERNAME=[email protected]
PASSWORD=<password>

MFA_SECRET=<secret>
# Service Role key allows Playwright to bypass row level security for test setup/cleanup. This is only needed for tests.
SERVICE_ROLE_KEY=<key>
# SUPABASE AUTH (when running Supabase Locally)
SUPABASE_AUTH_KEYCLOAK_CLIENT_ID=lfaiui
SUPABASE_AUTH_KEYCLOAK_SECRET=<secret>
Expand Down
20 changes: 15 additions & 5 deletions src/leapfrogai_ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,17 @@ Stop Supabase:

First install Playwright: `npm init playwright@latest`

To run the E2E tests, have the app running then:
To run the E2E tests:
`npm run test:integration:ui`
Click the play button in the Playwright UI.
Playwright will run it's own production build and server the app at `http://localhost:4173`. If you make server side changes,
restart playwright for them to take effect.

Notes:

1. Running the script above will reset the locally running Supabase instance and re-seed the database. You will
lose existing data.
2. if you run the tests in headless mode (```npm run test:integration```) you do not need the app running, it will build the app and run on port 4173.
lose existing data.
2. if you run the tests in headless mode (`npm run test:integration`) you do not need the app running, it will build the app and run on port 4173.

# Supabase and Keycloak Integration

Expand Down Expand Up @@ -120,7 +123,8 @@ don't work if you are using the Supabase CLI and not running a pure [docker vers
In order to fix this, we have to edit the /etc/hosts file in the running Supabase Auth container (we can't add this through a docker compose file
because we are using the Supabase CLI to start it up, migrate the db, and seed it).

`npm run supabase:start` will start Supabase and modify the /etc/hosts to properly direct requests to the Keycloak server.
`npm run supabase:start` will start Supabase and modify the /etc/hosts to properly direct requests to the Keycloak server. **You must ensure the IP address used
in this command is the correct IP for where you have Keycloak hosted.**
If you need to use a different Keycloak server for local development, you will need to modify this command.

If your Keycloak server is not at a hosted domain, you will also need to modify the /etc/hosts on your machine:
Expand All @@ -129,7 +133,7 @@ If your Keycloak server is not at a hosted domain, you will also need to modify
Example:
sudo nano /etc/hosts
*add this line (edit as required)*
100.104.70.77 keycloak.admin.uds.dev
xxx.xxx.xx.xx keycloak.admin.uds.dev
```

Ensure the
Expand Down Expand Up @@ -174,4 +178,10 @@ ex:
If you do not use this component and need to login with a Supabase auth command directly, ensure you provide
the "openid" scope with the options parameter.

The E2E tests use a fake Keycloak user. You must create this user in Keycloak, and add the username, password, and MFA secret
to the .env file in order for the tests to pass. PUBLIC_DISABLE_KEYCLOAK must also be set to false to run the E2E tests.

To get the MFA secret, create your new Keycloak user with the normal Keycloak login flow (not manually through the Keycloak UI).
When scanning the QR code, use an app that lets you see the url of the QR code. The secret is contained in that URL.

Login flow was adapted from [this reference](https://supabase.com/docs/guides/getting-started/tutorials/with-sveltekit?database-method=sql)
6 changes: 3 additions & 3 deletions src/leapfrogai_ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "supabase db reset && playwright test",
"test:integration:ui": "supabase db reset && playwright test --ui",
"test:integration": "playwright test",
"test:integration:ui": "playwright test --ui",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"prepare": "husky",
"supabase:start": "supabase start && docker exec -u 0 supabase_auth_supabase /bin/sh -c \"echo '100.104.70.77 keycloak.admin.uds.dev' >> /etc/hosts\"",
"supabase:start": "supabase start && docker exec -u 0 supabase_auth_supabase /bin/sh -c \"echo '100.115.154.78 keycloak.admin.uds.dev' >> /etc/hosts\"",
"supabase:stop": "supabase stop",
"supabase:reset": "supabase db reset",
"supbase:migrate": "supbase migration up"
Expand Down
12 changes: 7 additions & 5 deletions src/leapfrogai_ui/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ dotenv.config();
const config: PlaywrightTestConfig = {
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ name: 'clear_db', testMatch: /.*\clear_db\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Use prepared auth state.
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
dependencies: ['clear_db', 'setup']
},
{
name: 'firefox',
Expand All @@ -23,12 +24,12 @@ const config: PlaywrightTestConfig = {
// Use prepared auth state.
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
dependencies: ['clear_db', 'setup']
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup']
dependencies: ['clear_db', 'setup']
},
{
name: 'Edge',
Expand All @@ -37,12 +38,13 @@ const config: PlaywrightTestConfig = {
channel: 'msedge',
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
dependencies: ['clear_db', 'setup']
}
],
webServer: {
command: 'npm run build && npm run preview',
port: 4173
port: 4173,
stderr: 'pipe',
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
Expand Down
2 changes: 1 addition & 1 deletion src/leapfrogai_ui/src/lib/components/Toasts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
kind={toast.kind}
title={toast.title}
subtitle={toast.subtitle}
caption={new Date().toLocaleString()}
caption={`Time stamp [${new Date().toLocaleTimeString()}]`}
on:click={() => toastStore.dismissToast(toast.id)}
/>
</div>
Expand Down
14 changes: 0 additions & 14 deletions src/leapfrogai_ui/src/lib/mocks/chat-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,6 @@ export const mockChatCompletionError = () => {
);
};

// export const mockNewChatSubmission = (fakeConversation: Conversation, fakeMessage: Message) => {
// server.use(
// http.post('/', () => {
// return HttpResponse.json({
// type: 'success',
// status: 200,
// // Svelte form actions return data in a weird format that uses templating to build the object
// // not sure how to easily replicate this yet without hard coding the values like this
// data: `[{"newConversation":1},{"id":2,"user_id":3,"label":4,"inserted_at":5,"messages":6},"${fakeConversation.id}","${fakeConversation.user_id}","${fakeConversation.label}","${fakeConversation.inserted_at}",[7],{"id":8,"user_id":3,"conversation_id":2,"role":9,"content":4,"inserted_at":10},"${fakeMessage.id}","user","${fakeMessage.inserted_at}"]`
// });
// })
// );
// };

export const mockNewConversation = () => {
server.use(
http.post('/api/conversations/new', () => {
Expand Down
8 changes: 2 additions & 6 deletions src/leapfrogai_ui/src/lib/types/conversations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,15 @@ type Conversation = NewConversationInput & {
inserted_at: string;
};

type Roles = 'system' | 'user' | 'assistant' | 'function' | 'data' | 'tool';
type NewMessageInput = {
conversation_id: string;
content: string;
role: 'system' | 'user';
role: Roles;
inserted_at? : string;
};
type Message = NewMessageInput & {
id: string;
user_id: string;
inserted_at: string;
};

type AIMessage = {
role: 'user' | 'system';
content: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Button, TextInput, Tile } from 'carbon-components-svelte';
import { afterUpdate, onMount, tick } from 'svelte';
import { conversationsStore, toastStore } from '$stores';
import { ArrowRight, Attachment, UserAvatar } from 'carbon-icons-svelte';
import { ArrowRight, Attachment, StopFilledAlt, UserAvatar } from 'carbon-icons-svelte';
import { type Message as AIMessage, useChat } from 'ai/svelte';
import frog from '$assets/frog.png';
import { page } from '$app/stores';
Expand All @@ -19,7 +19,7 @@
$: $page.params.conversation_id, setMessages(activeConversation?.messages || []);
const { input, handleSubmit, messages, setMessages, isLoading, stop, error } = useChat({
const { input, handleSubmit, messages, setMessages, isLoading, stop } = useChat({
initialMessages: $conversationsStore.conversations
.find((conversation) => conversation.id === $page.params.conversation_id)
?.messages.map((message) => ({
Expand All @@ -32,21 +32,22 @@
await conversationsStore.newMessage({
conversation_id: activeConversation?.id,
content: message.content,
role: 'system'
role: message.role
});
}
},
onError: (error) => {
toastStore.addToast({
kind: 'error',
title: 'Error',
subtitle: 'Error getting AI Response'
});
}
});
$: if ($error)
toastStore.addToast({
kind: 'error',
title: 'Error',
subtitle: 'Error getting AI Response'
});
const onSubmit = async (e: SubmitEvent) => {
e.preventDefault();
if (!activeConversation?.id) {
// new conversation thread
await conversationsStore.newConversation($input);
Expand All @@ -73,11 +74,18 @@
const stopThenSave = async () => {
if ($isLoading) {
stop();
if (activeConversation?.id) {
toastStore.addToast({
kind: 'info',
title: 'Response Canceled',
subtitle: 'Response generation canceled.'
});
const lastMessage = $messages[$messages.length - 1];
if (activeConversation?.id && lastMessage.role !== 'user') {
await conversationsStore.newMessage({
conversation_id: activeConversation?.id,
content: $messages[$messages.length - 1].content, // save last message
role: 'system'
content: lastMessage.content, // save last message
role: lastMessage.role
});
}
}
Expand Down Expand Up @@ -130,16 +138,25 @@
placeholder="Type your message here..."
aria-label="message input"
/>

<Button
kind="secondary"
icon={ArrowRight}
iconDescription="Send"
size="field"
type="submit"
aria-label="send"
disabled={$isLoading || !$input}
/>
{#if !$isLoading}
<Button
kind="secondary"
icon={ArrowRight}
size="field"
type="submit"
aria-label="send"
disabled={$isLoading || !$input}
/>
{:else}
<Button
kind="secondary"
size="field"
type="submit"
icon={StopFilledAlt}
aria-label="cancel message"
on:click={stopThenSave}
/>
{/if}
</div>
</form>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, within } from '@testing-library/svelte';
import { render, screen } from '@testing-library/svelte';
import { conversationsStore } from '$stores';

import {
Expand Down Expand Up @@ -81,7 +81,7 @@ describe('The Chat Page', () => {
expect(input.value).toBe('');
});

it('disables the input while a response is being processed', async () => {
it('replaces submit with a cancel button while response is being processed', async () => {
const delayTime = 500;
mockNewConversation();
mockChatCompletion({ withDelay: true, delayTime: delayTime });
Expand All @@ -101,14 +101,12 @@ describe('The Chat Page', () => {
await user.type(input, question);
await user.click(submitBtn);

// submit is disabled while waiting for AI response
expect(submitBtn).toHaveProperty('disabled', true);
expect(screen.getByLabelText('cancel message')).toBeInTheDocument();

await delay(delayTime);

await user.type(input, 'new question');
// submit re-enabled after getting response
expect(submitBtn).toHaveProperty('disabled', false);
expect(screen.queryByLabelText('cancel message')).not.toBeInTheDocument();
});

it('displays a toast error notification when there is an error with the AI response', async () => {
Expand Down Expand Up @@ -194,6 +192,66 @@ describe('The Chat Page', () => {
await userEvent.click(submitBtn);
await screen.findAllByText('Error creating message.');
});
it('sends a toast when a message response is cancelled', async () => {
// Note - testing actual cancel with E2E test because the mockChatCompletion mock is no
// setup properly yet to return the AI responses
// Need an active conversation set to ensure the call to save the message is reached
vi.mock('$app/stores', (): typeof stores => {
const page: typeof stores.page = {
subscribe(fn) {
return getStores({
url: `http://localhost/chat/${fakeConversations[0].id}`,
params: { conversation_id: fakeConversations[0].id }
}).page.subscribe(fn);
}
};
const navigating: typeof stores.navigating = {
subscribe(fn) {
return getStores().navigating.subscribe(fn);
}
};
const updated: typeof stores.updated = {
subscribe(fn) {
return getStores().updated.subscribe(fn);
},
check: () => Promise.resolve(false)
};

return {
getStores,
navigating,
page,
updated
};
});

const delayTime = 500;
mockNewConversation();
mockChatCompletion({
withDelay: true,
delayTime: delayTime,
responseMsg: ['Fake', 'AI', 'Response']
});
mockNewMessage(fakeMessage);

conversationsStore.set({
conversations: [fakeConversations[0]]
});
const user = userEvent.setup();

const { getByLabelText } = render(ChatPageWithToast);

const input = getByLabelText('message input') as HTMLInputElement;
const submitBtn = getByLabelText('send');

await user.type(input, question);
await user.click(submitBtn);
await delay(delayTime / 2);
const cancelBtn = screen.getByLabelText('cancel message');
await user.click(cancelBtn);

await screen.findAllByText('Response Canceled');
});
});
});
});
2 changes: 1 addition & 1 deletion src/leapfrogai_ui/src/routes/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, locals: { getSession } }) => {
const session = await getSession();

// if the user is already logged in return them to the account page
// if the user is already logged in return them to the chat page
if (session) {
throw redirect(303, '/chat');
}
Expand Down
Loading

0 comments on commit 1d3a7f8

Please sign in to comment.