diff --git a/client/src/components/Conversations/Conversation.jsx b/client/src/components/Conversations/Conversation.jsx index 94c26713e..d2859c858 100644 --- a/client/src/components/Conversations/Conversation.jsx +++ b/client/src/components/Conversations/Conversation.jsx @@ -123,7 +123,7 @@ export default function Conversation({ conversation, retainView }) { /> ) : ( -
+
)} ); diff --git a/client/src/components/Input/NewConversationMenu/EndpointItem.jsx b/client/src/components/Input/NewConversationMenu/EndpointItem.jsx index 824ea9423..b3e07ef8c 100644 --- a/client/src/components/Input/NewConversationMenu/EndpointItem.jsx +++ b/client/src/components/Input/NewConversationMenu/EndpointItem.jsx @@ -6,6 +6,7 @@ import { useRecoilValue } from 'recoil'; import SetTokenDialog from '../SetTokenDialog'; import store from '../../../store'; +import { cn } from '~/utils/index.jsx'; const alternateName = { openAI: 'OpenAI', @@ -15,7 +16,7 @@ const alternateName = { google: 'PaLM' }; -export default function ModelItem({ endpoint, value }) { +export default function ModelItem({ endpoint, value, isSelected }) { const [setTokenDialogOpen, setSetTokenDialogOpen] = useState(false); const endpointsConfig = useRecoilValue(store.endpointsConfig); @@ -33,8 +34,11 @@ export default function ModelItem({ endpoint, value }) { <> {icon} {alternateName[endpoint] || endpoint} @@ -42,7 +46,10 @@ export default function ModelItem({ endpoint, value }) {
{isUserProvided ? ( - ); -}); - -export default DarkMode; diff --git a/client/src/components/Nav/NavLinks.jsx b/client/src/components/Nav/NavLinks.jsx index e12553c19..72085367c 100644 --- a/client/src/components/Nav/NavLinks.jsx +++ b/client/src/components/Nav/NavLinks.jsx @@ -3,11 +3,12 @@ import { Fragment, useState } from 'react'; import { useRecoilValue } from 'recoil'; import SearchBar from './SearchBar'; import TrashIcon from '../svg/TrashIcon'; +import GearIcon from '../svg/GearIcon'; +import Settings from './Settings'; import { Download } from 'lucide-react'; import NavLink from './NavLink'; import ExportModel from './ExportConversation/ExportModel'; import ClearConvos from './ClearConvos'; -import DarkMode from './DarkMode'; import Logout from './Logout'; import { useAuthContext } from '~/hooks/AuthContext'; import { cn } from '~/utils/'; @@ -18,6 +19,7 @@ import store from '~/store'; export default function NavLinks({ clearSearch, isSearchEnabled }) { const [showExports, setShowExports] = useState(false); const [showClearConvos, setShowClearConvos] = useState(false); + const [showSettings, setShowSettings] = useState(false); const { user } = useAuthContext(); const conversation = useRecoilValue(store.conversation) || {}; @@ -47,7 +49,7 @@ export default function NavLinks({ clearSearch, isSearchEnabled }) { @@ -78,7 +80,7 @@ export default function NavLinks({ clearSearch, isSearchEnabled }) { } text="Export conversation" @@ -86,9 +88,6 @@ export default function NavLinks({ clearSearch, isSearchEnabled }) { />
- - - setShowClearConvos(true)} /> + + } + text="Settings" + clickHandler={() => setShowSettings(true)} + /> +
@@ -108,6 +115,7 @@ export default function NavLinks({ clearSearch, isSearchEnabled }) { {showExports && } {showClearConvos && } + {showSettings && } ); } diff --git a/client/src/components/Nav/Settings.jsx b/client/src/components/Nav/Settings.jsx new file mode 100644 index 000000000..4e98f8573 --- /dev/null +++ b/client/src/components/Nav/Settings.jsx @@ -0,0 +1,170 @@ +import { Dialog } from '../ui/Dialog.tsx'; +import * as Tabs from '@radix-ui/react-tabs'; +import { DialogContent, DialogHeader, DialogTitle } from '../ui/Dialog.tsx'; +import { useEffect, useState, useContext } from 'react'; +import { cn } from '~/utils/'; +import { useClearConversationsMutation } from '~/data-provider'; +import { ThemeContext } from '~/hooks/ThemeContext'; +import store from '~/store'; +import { CheckIcon } from 'lucide-react'; + +export default function Settings({ open, onOpenChange }) { + const { theme, setTheme } = useContext(ThemeContext); + const { newConversation } = store.useConversation(); + const { refreshConversations } = store.useConversations(); + const clearConvosMutation = useClearConversationsMutation(); + const [isMobile, setIsMobile] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + + const clearConvos = () => { + if (confirmClear) { + console.log('Clearing conversations...'); + clearConvosMutation.mutate(); + setConfirmClear(false); + } else { + setConfirmClear(true); + } + }; + + const changeTheme = (e) => { + setTheme(e.target.value); + }; + + // check if mobile dynamically and update + useEffect(() => { + const checkMobile = () => { + if (window.innerWidth <= 768) { + setIsMobile(true); + } else { + setIsMobile(false); + } + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + }, []); + + useEffect(() => { + if (clearConvosMutation.isSuccess) { + newConversation(); + refreshConversations(); + } + }, [clearConvosMutation.isSuccess]); + + useEffect(() => { + // If the user clicks in the dialog when confirmClear is true, set it to false + const handleClick = (e) => { + if (confirmClear) { + if (e.target.id === 'clearConvosBtn' || e.target.id === 'clearConvosTxt') { + return; + } + + setConfirmClear(false); + } + }; + + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); + }, [confirmClear]); + + return ( + + + + Settings + +
+ + + + + + + General + + + + +
+
+
+
Theme
+ +
+
+
+
+
Clear all chats
+ +
+
+
+
+
+
+
+
+ ); +} diff --git a/client/src/components/svg/GearIcon.jsx b/client/src/components/svg/GearIcon.jsx new file mode 100644 index 000000000..2f14b21d3 --- /dev/null +++ b/client/src/components/svg/GearIcon.jsx @@ -0,0 +1,19 @@ +export default function GearIcon() { + return ( + + + + + ); +} diff --git a/client/src/components/ui/AlertDialog.tsx b/client/src/components/ui/AlertDialog.tsx index bc0e7f43f..faa0b670f 100644 --- a/client/src/components/ui/AlertDialog.tsx +++ b/client/src/components/ui/AlertDialog.tsx @@ -28,7 +28,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, children, ...props }, ref) => ( (({ className, children, ...props }, ref) => ( {children} - + Close @@ -59,13 +59,19 @@ const DialogContent = React.forwardRef< DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); DialogHeader.displayName = 'DialogHeader'; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); diff --git a/client/src/components/ui/DialogTemplate.jsx b/client/src/components/ui/DialogTemplate.jsx index 0dc932ef4..eccb3010a 100644 --- a/client/src/components/ui/DialogTemplate.jsx +++ b/client/src/components/ui/DialogTemplate.jsx @@ -10,28 +10,22 @@ import { import { cn } from '~/utils/'; const DialogTemplate = forwardRef((props, ref) => { - const { - title, - description, - main, - buttons, - leftButtons, - selection, - className - } = props; + const { title, description, main, buttons, leftButtons, selection, className } = props; const { selectHandler, selectClasses, selectText } = selection || {}; const defaultSelect = 'bg-gray-900 text-white transition-colors hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900'; return ( - + {title} - - {description} - + {description && ( + + {description} + + )} - {main ? main : null} +
{main ? main : null}
{leftButtons ? leftButtons : null}
diff --git a/client/src/hooks/ThemeContext.jsx b/client/src/hooks/ThemeContext.jsx index f575ab911..530f606f0 100644 --- a/client/src/hooks/ThemeContext.jsx +++ b/client/src/hooks/ThemeContext.jsx @@ -26,10 +26,14 @@ export const ThemeProvider = ({ initialTheme, children }) => { const rawSetTheme = (rawTheme) => { const root = window.document.documentElement; - const isDark = rawTheme === 'dark'; + let isDark = rawTheme === 'dark'; + + if (rawTheme === 'system') { + isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + } root.classList.remove(isDark ? 'light' : 'dark'); - root.classList.add(rawTheme); + root.classList.add(isDark ? 'dark' : 'light'); localStorage.setItem('color-theme', rawTheme); }; diff --git a/client/src/style.css b/client/src/style.css index bdbbea2b7..0cff4afb9 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -132,6 +132,24 @@ transition: all 1s ease-in-out; } */ +select { + --tw-shadow: 0 0 transparent; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%238e8ea0' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E"); + background-position: right .5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + background-color: #fff; + border-color: #8e8ea0; + border-radius: 0; + border-width: 1px; + font-size: 1rem; + line-height: 1.5rem; + padding: .5rem .75rem; +} + .overflow-y-auto { overflow-y: overlay; } diff --git a/client/src/utils/buildTree.js b/client/src/utils/buildTree.js index 4b5e0e0dd..b35e5eb3f 100644 --- a/client/src/utils/buildTree.js +++ b/client/src/utils/buildTree.js @@ -1,7 +1,7 @@ const even = 'w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 bg-white dark:text-gray-100 group dark:bg-gray-800 hover:bg-gray-100/25 hover:text-gray-700 dark:hover:bg-gray-900 dark:hover:text-gray-200'; const odd = - 'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-[#444654] hover:bg-gray-100/40 hover:text-gray-700 dark:hover:bg-[#3b3d49] dark:hover:text-gray-200'; + 'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-gray-1000 hover:bg-gray-100/40 hover:text-gray-700 dark:hover:bg-[#3b3d49] dark:hover:text-gray-200'; export default function buildTree(messages, groupAll = false) { if (messages === null) return null; diff --git a/client/src/utils/getError.ts b/client/src/utils/getError.ts new file mode 100644 index 000000000..915aa86ac --- /dev/null +++ b/client/src/utils/getError.ts @@ -0,0 +1,29 @@ + +const isJson = (str) => { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; +} + +const getError = (text) => { + const errorMessage = text.length > 512 ? text.slice(0, 512) + '...' : text; + const match = text.match(/\{[^{}]*\}/); + let json = match ? match[0] : ''; + if (isJson(json)) { + json = JSON.parse(json); + if (json.code === 'invalid_api_key') { + return 'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.'; + } else if (json.type === 'insufficient_quota') { + return 'We apologize for any inconvenience caused. The default API key has reached its limit. To continue using this service, please set up your own API key. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.'; + } else { + return `Oops! Something went wrong. Please try again in a few moments. Here's the specific error message we encountered: ${errorMessage}`; + } + } else { + return `Oops! Something went wrong. Please try again in a few moments. Here's the specific error message we encountered: ${errorMessage}`; + } +}; + +export default getError; \ No newline at end of file diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 6e118d2f0..92259f85f 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -39,7 +39,8 @@ module.exports = { '600': '#565869', '700': '#40414f', // Replacing .dark .dark:bg-gray-700 and .dark .dark:hover:bg-gray-700:hover '800': '#343541', // Replacing .dark .dark:bg-gray-800, .bg-gray-800, and .dark .dark:hover:bg-gray-800\/90 - '900': '#202123' // Replacing .dark .dark:bg-gray-900, .bg-gray-900, and .dark .dark:hover:bg-gray-900:hover + '900': '#202123', // Replacing .dark .dark:bg-gray-900, .bg-gray-900, and .dark .dark:hover:bg-gray-900:hover + '1000': '#444654' }, green: { 50: "#f1f9f7", diff --git a/e2e/specs/landing.spec.js b/e2e/specs/landing.spec.js index 0b8072d6e..bc88c4e93 100644 --- a/e2e/specs/landing.spec.js +++ b/e2e/specs/landing.spec.js @@ -12,7 +12,41 @@ test.describe('Landing suite', () => { test('Landing title', async () => { const page = await myBrowser.newPage(); await page.goto('http://localhost:3080/'); - const pageTitle = await page.textContent('#landing-title') + const pageTitle = await page.textContent('#landing-title'); expect(pageTitle.length).toBeGreaterThan(0); }); + + test('Create Conversation', async () => { + const page = await myBrowser.newPage(); + await page.goto('http://localhost:3080/'); + + async function getItems() { + const navDiv = await page.waitForSelector('nav > div'); + if (!navDiv) { + return []; + } + + const items = await navDiv.$$('a.group'); + return items || []; + } + + // Wait for the page to load and the SVG loader to disappear + await page.waitForSelector('nav > div'); + await page.waitForSelector('nav > div > div > svg', { state: 'detached' }); + + let beforeAdding = (await getItems()).length; + + const input = await page.locator('form').getByRole('textbox'); + await input.click(); + await input.fill('Hi!'); + + // Send the message + await page.locator('form').getByRole('button').nth(1).click(); + + // Wait for the message to be sent + await page.waitForTimeout(15000); + let afterAdding = (await getItems()).length; + + expect(afterAdding).toBeGreaterThan(beforeAdding); + }); }); diff --git a/e2e/specs/nav.spec.js b/e2e/specs/nav.spec.js new file mode 100644 index 000000000..429f0790b --- /dev/null +++ b/e2e/specs/nav.spec.js @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Navigation suite', () => { + let myBrowser; + + test.beforeEach(async ({ browser }) => { + myBrowser = await browser.newContext({ + storageState: 'e2e/auth.json', + }); + }); + + test('Navigation bar', async () => { + const page = await myBrowser.newPage(); + await page.goto('http://localhost:3080/'); + + await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').click(); + const navBar = await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').isVisible(); + expect(navBar).toBeTruthy(); + }); + + test('Settings modal', async () => { + const page = await myBrowser.newPage(); + await page.goto('http://localhost:3080/'); + await page.locator('[id="headlessui-menu-button-\\:r0\\:"]').click(); + await page.getByText('Settings').click(); + + const modal = await page.getByRole('dialog', { name: 'Settings' }).isVisible(); + expect(modal).toBeTruthy(); + + const modalTitle = await page.getByRole('heading', { name: 'Settings' }).textContent(); + expect(modalTitle.length).toBeGreaterThan(0); + expect(modalTitle).toEqual('Settings'); + + const modalTabList = await page.getByRole('tablist', { name: 'Settings' }).isVisible(); + expect(modalTabList).toBeTruthy(); + + const generalTabPanel = await page.getByRole('tabpanel', { name: 'General' }).isVisible(); + expect(generalTabPanel).toBeTruthy(); + + const modalClearConvos = await page.getByRole('button', { name: 'Clear' }).isVisible(); + expect(modalClearConvos).toBeTruthy(); + + const modalTheme = await page.getByRole('combobox'); + expect(modalTheme.isVisible()).toBeTruthy(); + + async function changeMode(theme) { + // change the value to 'dark' and 'light' and see if the theme changes + await modalTheme.selectOption({ label: theme }); + await page.waitForTimeout(1000); + + // Check if the HTML element has the theme class + const html = await page.$eval('html', (element, theme) => element.classList.contains(theme.toLowerCase()), theme); + expect(html).toBeTruthy(); + } + + await changeMode('Dark'); + await changeMode('Light'); + }); +}); diff --git a/e2e/specs/popup.spec.js b/e2e/specs/popup.spec.js new file mode 100644 index 000000000..09158516a --- /dev/null +++ b/e2e/specs/popup.spec.js @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Endpoints Presets suite', () => { + let myBrowser; + + test.beforeEach(async ({ browser }) => { + myBrowser = await browser.newContext({ + storageState: 'e2e/auth.json', + }); + }); + + test('Endpoints Suite', async () => { + const page = await myBrowser.newPage(); + await page.goto('http://localhost:3080/'); + await page.getByRole('button', { name: 'New Topic' }).click(); + + const endpointItem = await page.getByRole('menuitemradio', { name: 'OpenAI' }) + await endpointItem.click(); + + await page.getByRole('button', { name: 'New Topic' }).click(); + // Check if the active class is set on the selected endpoint + expect(await endpointItem.getAttribute('class')).toContain('active'); + }); +});