From dab78117764fc5c5804ee9b96288101667c0508c Mon Sep 17 00:00:00 2001 From: Tristan Chin <23557893+maxijonson@users.noreply.github.com> Date: Tue, 18 Jul 2023 17:28:24 -0400 Subject: [PATCH] [web] Conversation edit/duplicate/import/export (#39) --- packages/lib/src/classes/Conversation.ts | 30 ++- packages/web/src/changelog/index.ts | 3 +- packages/web/src/changelog/v4-5-0.ts | 33 +++ .../web/src/components/AddConversation.tsx | 99 ++++++-- .../components/AppSettings/AppSettings.tsx | 83 +++---- .../AppSettings/AppSettingsDangerZone.tsx | 27 +- .../AppSettings/AppStorageUsage.tsx | 12 +- .../components/AppSettings/DataSettings.tsx | 17 ++ .../AppSettings/GeneralSettings.tsx | 63 +++++ .../CallableFunctionCardMenu.tsx | 11 +- .../CallableFunctionImportDropzone.tsx | 33 +-- .../src/components/Changelog/Changelog.tsx | 2 +- .../ConversationNavbar/ConversationNavbar.tsx | 8 +- .../SettingsButton.tsx | 19 +- .../NavbarConversation.tsx | 232 ------------------ .../NavbarConversation/NavbarConversation.tsx | 99 ++++++++ .../NavbarConversationMenu.tsx | 181 ++++++++++++++ .../NavbarConversations.tsx | 121 ++++----- .../NavbarConversation.tsx | 232 ------------------ .../CallableFunctionParameterForm.tsx | 2 +- .../ConversationForm/ConversationForm.tsx | 96 +++++--- .../ConversationFormAdvancedTab.tsx | 68 +++++ .../ConversationFormConversationTab.tsx | 81 +----- .../inputs/ConversationDropzone.tsx | 147 +++++++++++ .../modals/ConversationNameEditModal.tsx | 105 ++++++++ .../components/modals/SettingsFormModal.tsx | 46 ---- .../providers/ConversationFormProvider.tsx | 53 +++- .../web/src/entities/conversationExport.ts | 18 ++ .../src/entities/persistenceAppSettings.ts | 1 + .../entities/persistenceCallableFunction.ts | 2 +- .../src/entities/persistenceConversation.ts | 2 +- .../src/entities/persistenceSavedContext.ts | 2 +- .../src/entities/persistenceSavedPrompt.ts | 2 +- .../toggleShowConversationImport.ts | 11 + .../actions/conversations/addConversation.ts | 27 +- .../conversations/duplicateConversation.ts | 32 +++ .../actions/conversations/editConversation.ts | 57 +++++ .../conversations/importConversations.ts | 30 +++ .../persistence/addPersistedConversationId.ts | 4 +- .../removePersistedConversationId.ts | 13 + .../useGenerateConversationName.ts | 92 +++++++ .../1689537326770_conversation-export.ts | 10 + .../web/src/store/persist/migrations/index.ts | 6 +- .../src/store/persist/parsePersistedState.ts | 1 + .../web/src/store/persist/partializeStore.ts | 1 + .../web/src/store/slices/appSettingsSlice.ts | 2 + packages/web/src/utils/readJsonFile.ts | 17 ++ 47 files changed, 1401 insertions(+), 832 deletions(-) create mode 100644 packages/web/src/changelog/v4-5-0.ts create mode 100644 packages/web/src/components/AppSettings/DataSettings.tsx create mode 100644 packages/web/src/components/AppSettings/GeneralSettings.tsx delete mode 100644 packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation.tsx create mode 100644 packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversation.tsx create mode 100644 packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversationMenu.tsx delete mode 100644 packages/web/src/components/NavbarConversations/NavbarConversation.tsx create mode 100644 packages/web/src/components/forms/ConversationForm/ConversationFormAdvancedTab.tsx create mode 100644 packages/web/src/components/inputs/ConversationDropzone.tsx create mode 100644 packages/web/src/components/modals/ConversationNameEditModal.tsx delete mode 100644 packages/web/src/components/modals/SettingsFormModal.tsx create mode 100644 packages/web/src/entities/conversationExport.ts create mode 100644 packages/web/src/store/actions/appSettings/toggleShowConversationImport.ts create mode 100644 packages/web/src/store/actions/conversations/duplicateConversation.ts create mode 100644 packages/web/src/store/actions/conversations/editConversation.ts create mode 100644 packages/web/src/store/actions/conversations/importConversations.ts create mode 100644 packages/web/src/store/actions/persistence/removePersistedConversationId.ts create mode 100644 packages/web/src/store/hooks/conversations/useGenerateConversationName.ts create mode 100644 packages/web/src/store/persist/migrations/1689537326770_conversation-export.ts create mode 100644 packages/web/src/utils/readJsonFile.ts diff --git a/packages/lib/src/classes/Conversation.ts b/packages/lib/src/classes/Conversation.ts index 4bc8a26..1c02495 100644 --- a/packages/lib/src/classes/Conversation.ts +++ b/packages/lib/src/classes/Conversation.ts @@ -282,6 +282,13 @@ export class Conversation { this.functions = this.functions.filter((fn) => fn.id !== id); } + /** + * Removes all functions from the conversation. + */ + public clearFunctions() { + this.functions = []; + } + /** * Adds a function to the conversation. This function can be "called" by the assistant, generating a function call message. * @@ -567,14 +574,14 @@ export class Conversation { return { ...this.config.chatCompletionConfig, ...this.config.config, - }; + } satisfies ConversationConfigParameters; } /** * Assigns a new config to the conversation. * * @param config The new config to use. - * @param merge Set to `true` to merge the new config with the existing config instead of replacing it. + * @param merge Set to `true` to shallow merge the new config with the existing config instead of replacing it. */ public setConfig(config: ConversationConfigParameters, merge = false) { const newConfig = merge ? { ...this.getConfig(), ...config } : config; @@ -588,6 +595,25 @@ export class Conversation { } } + /** + * Gets the current request options of the conversation. + */ + public getRequestOptions() { + return this.requestOptions; + } + + /** + * Sets new request options to be used as defaults for all HTTP requests made by this conversation. + * + * @param requestOptions The new request options to use. + * @param merge Set to `true` to shallow merge the new request options with the existing request options instead of replacing them. + */ + public setRequestOptions(requestOptions: RequestOptions, merge = false) { + this.requestOptions = merge + ? { ...this.requestOptions, ...requestOptions } + : requestOptions; + } + private notifyMessageAdded(message: Message) { this.addMessageListeners.forEach((listener) => listener(message)); } diff --git a/packages/web/src/changelog/index.ts b/packages/web/src/changelog/index.ts index 15b6432..d55b9e4 100644 --- a/packages/web/src/changelog/index.ts +++ b/packages/web/src/changelog/index.ts @@ -1,6 +1,7 @@ import { ReactNode } from "react"; import v4_4_0 from "./v4-4-0"; import v4_4_1 from "./v4-4-1"; +import v4_5_0 from "./v4-5-0"; export type ChangelogEntrySection = { label: ReactNode; @@ -16,4 +17,4 @@ export type ChangelogEntry = { }; // First entry is the latest version -export const changelog = [v4_4_1, v4_4_0]; +export const changelog = [v4_5_0, v4_4_1, v4_4_0]; diff --git a/packages/web/src/changelog/v4-5-0.ts b/packages/web/src/changelog/v4-5-0.ts new file mode 100644 index 0000000..21630f7 --- /dev/null +++ b/packages/web/src/changelog/v4-5-0.ts @@ -0,0 +1,33 @@ +import { ChangelogEntry } from "."; +import { CHANGELOG_SECTION } from "../config/constants"; + +const v4_5_0: ChangelogEntry = { + version: "4.5.0 - Conversation Edit, Duplicate and Export", + date: new Date("july 16 2023"), + sections: [ + { + label: CHANGELOG_SECTION.FEATURES, + items: [ + "Added the ability to change conversation settings", + "Added the ability to duplicate conversations", + "Added the ability to import/export conversations", + "Added the ability to generate a conversation name using the assistant.", + ], + }, + { + label: CHANGELOG_SECTION.IMPROVEMENTS, + items: [ + "Moved the advanced conversation settings to their own tab", + "Default settings are now saved in the conversation form", + ], + }, + { + label: CHANGELOG_SECTION.REMOVALS, + items: [ + "Removed the default conversation settings from the settings popup. This is now done directly in the conversation form.", + ], + }, + ], +}; + +export default v4_5_0; diff --git a/packages/web/src/components/AddConversation.tsx b/packages/web/src/components/AddConversation.tsx index f82dc4d..2ceb4c4 100644 --- a/packages/web/src/components/AddConversation.tsx +++ b/packages/web/src/components/AddConversation.tsx @@ -5,14 +5,20 @@ import { Title, Card, createStyles, + Button, + Box, } from "@mantine/core"; import React from "react"; import { ConversationFormValues } from "../contexts/ConversationFormContext"; import { addConversation } from "../store/actions/conversations/addConversation"; import { setActiveConversation } from "../store/actions/conversations/setActiveConversation"; -import { addPersistedConversationId } from "../store/actions/persistence/addPersistedConversationId"; import ConversationForm from "./forms/ConversationForm/ConversationForm"; -import { useGetFunction } from "../store/hooks/callableFunctions/useGetFunction"; +import { BiImport } from "react-icons/bi"; +import ConversationDropzone, { + ConversationNavbarDropzoneProps, +} from "./inputs/ConversationDropzone"; +import { importConversations } from "../store/actions/conversations/importConversations"; +import { useAppStore } from "../store"; const useStyles = createStyles((theme) => ({ card: { @@ -28,8 +34,11 @@ const useStyles = createStyles((theme) => ({ })); const AddConversation = () => { + const showConversationImport = useAppStore( + (state) => state.showConversationImport + ); const { classes } = useStyles(); - const getFunction = useGetFunction(); + const dropzoneOpenRef = React.useRef<() => void>(null); const onSubmit = React.useCallback( ({ @@ -39,40 +48,76 @@ const AddConversation = () => { functionIds, ...values }: ConversationFormValues) => { - const newConversation = addConversation(values, { headers, proxy }); + const newConversation = addConversation( + values, + { headers, proxy }, + functionIds, + save + ); setActiveConversation(newConversation.id, true); + }, + [] + ); - for (const functionId of functionIds) { - const callableFunction = getFunction(functionId); - if (callableFunction) { - newConversation.addFunction(callableFunction); - } - } + const onDrop = React.useCallback( + async (importedConversations) => { + const conversations = await importConversations( + importedConversations + ); - if (save) { - addPersistedConversationId(newConversation.id); + if (conversations.length) { + setActiveConversation(conversations[0].id); } }, - [getFunction] + [] ); return (
- - - - New Conversation - - - - + + + + + New Conversation + + + + + {showConversationImport && ( + + + + + + + + )} +
); diff --git a/packages/web/src/components/AppSettings/AppSettings.tsx b/packages/web/src/components/AppSettings/AppSettings.tsx index 5637a10..947fb95 100644 --- a/packages/web/src/components/AppSettings/AppSettings.tsx +++ b/packages/web/src/components/AppSettings/AppSettings.tsx @@ -1,52 +1,45 @@ -import { - ColorScheme, - Divider, - Group, - SegmentedControl, - Stack, - Switch, - Text, - useMantineColorScheme, -} from "@mantine/core"; -import AppStorageUsage from "./AppStorageUsage"; -import { useAppStore } from "../../store"; -import { toggleShowUsage } from "../../store/actions/appSettings/toggleShowUsage"; -import AppSettingsDangerZone from "./AppSettingsDangerZone"; +import { Tabs, useMantineTheme } from "@mantine/core"; +import GeneralSettings from "./GeneralSettings"; +import React from "react"; +import { useMediaQuery } from "@mantine/hooks"; +import { BiCog, BiData } from "react-icons/bi"; +import DataSettings from "./DataSettings"; -const AppSettings = () => { - const { colorScheme, toggleColorScheme } = useMantineColorScheme(); - const showUsage = useAppStore((state) => state.showUsage); - - return ( - - - Theme - - toggleColorScheme(value as ColorScheme) - } - /> - +type AppSettingsTab = "general" | "data"; - - Show Usage - toggleShowUsage()} - /> - - - +const AppSettings = () => { + const theme = useMantineTheme(); + const isSm = useMediaQuery(`(max-width: ${theme.breakpoints.md})`); - + const [currentTab, setCurrentTab] = + React.useState("general"); - - + return ( + + setCurrentTab((tab as AppSettingsTab) ?? "general") + } + orientation={isSm ? "horizontal" : "vertical"} + mih="40vh" + variant="outline" + > + + }> + General + + }> + Data + + + + + + + + + ); }; diff --git a/packages/web/src/components/AppSettings/AppSettingsDangerZone.tsx b/packages/web/src/components/AppSettings/AppSettingsDangerZone.tsx index 965485b..aed7657 100644 --- a/packages/web/src/components/AppSettings/AppSettingsDangerZone.tsx +++ b/packages/web/src/components/AppSettings/AppSettingsDangerZone.tsx @@ -7,27 +7,39 @@ import { resetCallableFunctionWarnings } from "../../store/actions/callableFunct import { resetDefaultSettings } from "../../store/actions/defaultConversationSettings/resetDefaultSettings"; import { removeAllSavedContexts } from "../../store/actions/savedContexts/removeAllSavedContexts"; import { removeAllSavedPrompts } from "../../store/actions/savedPrompts/removeAllSavedPrompts"; +import { useAppStore } from "../../store"; +import { shallow } from "zustand/shallow"; const AppSettingsDangerZone = () => { + const [conversations, functions, contexts, prompts] = useAppStore( + (state) => [ + state.conversations, + state.callableFunctions, + state.savedContexts, + state.savedPrompts, + ], + shallow + ); + const actions = React.useMemo( () => [ { - label: "Delete conversations", + label: `Delete all conversations (${conversations.length})`, unconfirmedLabel: "Delete", action: removeAllConversations, }, { - label: "Delete Functions", + label: `Delete all functions (${functions.length})`, unconfirmedLabel: "Delete", action: removeAllCallableFunctions, }, { - label: "Delete Saved Contexts", + label: `Delete all saved contexts (${contexts.length})`, unconfirmedLabel: "Delete", action: removeAllSavedContexts, }, { - label: "Delete Saved Prompts", + label: `Delete all saved prompts (${prompts.length})`, unconfirmedLabel: "Delete", action: removeAllSavedPrompts, }, @@ -42,7 +54,12 @@ const AppSettingsDangerZone = () => { action: resetDefaultSettings, }, ], - [] + [ + contexts.length, + conversations.length, + functions.length, + prompts.length, + ] ); return ( diff --git a/packages/web/src/components/AppSettings/AppStorageUsage.tsx b/packages/web/src/components/AppSettings/AppStorageUsage.tsx index 7b236ea..8425dc1 100644 --- a/packages/web/src/components/AppSettings/AppStorageUsage.tsx +++ b/packages/web/src/components/AppSettings/AppStorageUsage.tsx @@ -16,6 +16,7 @@ interface StorageUsage { } const MIN_SIZE = 100; +const QUOTA = 5 * 1024 * 1024; // Assuming 5MB, since that's the default for most browsers const getUsageLabel = (key: string) => { const words = key.split(/(?=[A-Z])/); @@ -87,7 +88,6 @@ const AppStorageUsage = () => { ); const sections = React.useMemo(() => { - const quota = 5 * 1024 * 1024; // Assuming 5MB, since that's the default for most browsers const total = usage.reduce((acc, { size }) => acc + size, 0); return usage @@ -95,17 +95,17 @@ const AppStorageUsage = () => { const color = colors[i % colors.length]; return { - value: (size / quota) * 100, + value: (size / QUOTA) * 100, color, label, tooltip: `${label} - ${getSizeLabel(size)}`, }; }) .concat({ - value: ((quota - total) / quota) * 100, + value: ((QUOTA - total) / QUOTA) * 100, color: theme.colors.dark[2], label: "Available", - tooltip: `Available - ${getSizeLabel(quota - total)}`, + tooltip: `Available - ${getSizeLabel(QUOTA - total)}`, }); }, [colors, theme.colors.dark, usage]); @@ -113,8 +113,8 @@ const AppStorageUsage = () => { Storage Usage - This is an estimate, assuming a 5MB quota. (default for most - browsers). + This is an estimate, assuming a {getSizeLabel(QUOTA)} quota. + (default for most browsers) Categories under {getSizeLabel(MIN_SIZE)} are not shown. diff --git a/packages/web/src/components/AppSettings/DataSettings.tsx b/packages/web/src/components/AppSettings/DataSettings.tsx new file mode 100644 index 0000000..406775b --- /dev/null +++ b/packages/web/src/components/AppSettings/DataSettings.tsx @@ -0,0 +1,17 @@ +import { Divider, Stack } from "@mantine/core"; +import AppSettingsDangerZone from "./AppSettingsDangerZone"; +import AppStorageUsage from "./AppStorageUsage"; + +const DataSettings = () => { + return ( + + + + + + + + ); +}; + +export default DataSettings; diff --git a/packages/web/src/components/AppSettings/GeneralSettings.tsx b/packages/web/src/components/AppSettings/GeneralSettings.tsx new file mode 100644 index 0000000..f888103 --- /dev/null +++ b/packages/web/src/components/AppSettings/GeneralSettings.tsx @@ -0,0 +1,63 @@ +import { + ColorScheme, + Group, + SegmentedControl, + Stack, + Switch, + Text, + useMantineColorScheme, +} from "@mantine/core"; +import { useAppStore } from "../../store"; +import { toggleShowUsage } from "../../store/actions/appSettings/toggleShowUsage"; +import { shallow } from "zustand/shallow"; +import { toggleShowConversationImport } from "../../store/actions/appSettings/toggleShowConversationImport"; + +const GeneralSettings = () => { + const { colorScheme, toggleColorScheme } = useMantineColorScheme(); + const [showUsage, showConversationImport] = useAppStore( + (state) => [state.showUsage, state.showConversationImport], + shallow + ); + + return ( + + + Theme + + toggleColorScheme(value as ColorScheme) + } + /> + + + + Show Conversation Usage + toggleShowUsage()} + /> + + + + + Show Conversation Import + + You can also import conversations by dropping them onto + the navbar. + + + toggleShowConversationImport()} + /> + + + ); +}; + +export default GeneralSettings; diff --git a/packages/web/src/components/CallableFunctionCard/CallableFunctionCardMenu.tsx b/packages/web/src/components/CallableFunctionCard/CallableFunctionCardMenu.tsx index a82063d..4b0cb57 100644 --- a/packages/web/src/components/CallableFunctionCard/CallableFunctionCardMenu.tsx +++ b/packages/web/src/components/CallableFunctionCard/CallableFunctionCardMenu.tsx @@ -37,6 +37,10 @@ const CallableFunctionCardMenu = ({ id }: CallableFunctionCardMenuProps) => { deleteCallableFunction(fn.id); }, [fn]); + const onDuplicate = React.useCallback(() => { + duplicateCallableFunction(id); + }, [id]); + const onExport = React.useCallback(() => { if (!fn) return; const data = JSON.stringify( @@ -50,7 +54,7 @@ const CallableFunctionCardMenu = ({ id }: CallableFunctionCardMenuProps) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${fn.id}.json`; + a.download = `gptturbo-function-${fn.id}.json`; a.click(); URL.revokeObjectURL(url); }, [fn, getFunctionCode, getFunctionDisplayName]); @@ -66,10 +70,7 @@ const CallableFunctionCardMenu = ({ id }: CallableFunctionCardMenuProps) => { }> Edit - duplicateCallableFunction(id)} - icon={} - > + }> Duplicate }> diff --git a/packages/web/src/components/CallableFunctionImport/CallableFunctionImportDropzone.tsx b/packages/web/src/components/CallableFunctionImport/CallableFunctionImportDropzone.tsx index 5c26147..87838f1 100644 --- a/packages/web/src/components/CallableFunctionImport/CallableFunctionImportDropzone.tsx +++ b/packages/web/src/components/CallableFunctionImport/CallableFunctionImportDropzone.tsx @@ -6,33 +6,9 @@ import { CallableFunctionExport, callableFunctionExportschema, } from "../../entities/callableFunctionExport"; -import getErrorInfo from "../../utils/getErrorInfo"; import { BiX, BiUpload } from "react-icons/bi"; import { BsFiletypeJson } from "react-icons/bs"; - -const readImportedFile = (file: File) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - try { - const result = callableFunctionExportschema.safeParse( - JSON.parse(reader.result as string) - ); - if (!result.success) { - reject(result.error); - } else { - resolve(result.data); - } - } catch (e) { - reject(e); - } - }; - reader.onerror = (e) => { - reject(e); - }; - reader.readAsText(file); - }); -}; +import readJsonFile from "../../utils/readJsonFile"; interface CallableFunctionImportDropzoneProps { onDrop: (fns: CallableFunctionExport[]) => void; @@ -48,11 +24,14 @@ const CallableFunctionImportDropzone = ({ const importedFns = await Promise.all( files.map(async (file) => { try { - return await readImportedFile(file); + return await readJsonFile( + file, + callableFunctionExportschema + ); } catch (e) { notifications.show({ title: "Failed to import function", - message: getErrorInfo(e).message, + message: "Not a valid function file", color: "red", icon: , }); diff --git a/packages/web/src/components/Changelog/Changelog.tsx b/packages/web/src/components/Changelog/Changelog.tsx index e1677be..e946cb5 100644 --- a/packages/web/src/components/Changelog/Changelog.tsx +++ b/packages/web/src/components/Changelog/Changelog.tsx @@ -21,7 +21,7 @@ const dateStr = (date: Date) => { const Changelog = () => { return ( - + {changelog.map( ({ version, date, sections, description }, i) => ( diff --git a/packages/web/src/components/ConversationNavbar/ConversationNavbar.tsx b/packages/web/src/components/ConversationNavbar/ConversationNavbar.tsx index 2d8ae22..cfd075d 100644 --- a/packages/web/src/components/ConversationNavbar/ConversationNavbar.tsx +++ b/packages/web/src/components/ConversationNavbar/ConversationNavbar.tsx @@ -8,6 +8,8 @@ import { useActiveConversation } from "../../store/hooks/conversations/useActive import useConversationNavbar from "../../contexts/hooks/useConversationNavbar"; import ConversationNavbarBurger from "./ConversationNavbarBurger"; import ConversationNavbarFooter from "./ConversationNavbarFooter/ConversationNavbarFooter"; +import ConversationDropzone from "../inputs/ConversationDropzone"; +import { importConversations } from "../../store/actions/conversations/importConversations"; const ConversationNavbar = () => { const activeConversation = useActiveConversation(); @@ -31,7 +33,11 @@ const ConversationNavbar = () => { - + + + {activeConversation && showUsage && ( diff --git a/packages/web/src/components/ConversationNavbar/ConversationNavbarHeader/SettingsButton.tsx b/packages/web/src/components/ConversationNavbar/ConversationNavbarHeader/SettingsButton.tsx index 766244d..4583dbe 100644 --- a/packages/web/src/components/ConversationNavbar/ConversationNavbarHeader/SettingsButton.tsx +++ b/packages/web/src/components/ConversationNavbar/ConversationNavbarHeader/SettingsButton.tsx @@ -1,9 +1,12 @@ -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { BiCog } from "react-icons/bi"; import TippedActionIcon from "../../common/TippedActionIcon"; -import SettingsFormModal from "../../modals/SettingsFormModal"; +import AppSettings from "../../AppSettings/AppSettings"; +import { Modal, useMantineTheme } from "@mantine/core"; const SettingsButton = () => { + const theme = useMantineTheme(); + const isSm = useMediaQuery(`(max-width: ${theme.breakpoints.md})`); const [settingsOpened, { open: openSettings, close: closeSettings }] = useDisclosure(); @@ -11,15 +14,21 @@ const SettingsButton = () => { <> - + fullScreen={isSm} + size="xl" + title="Settings" + > + + ); }; diff --git a/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation.tsx b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation.tsx deleted file mode 100644 index a27d0a0..0000000 --- a/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { - Anchor, - Group, - Stack, - Text, - TextInput, - createStyles, -} from "@mantine/core"; -import { Conversation } from "gpt-turbo"; -import { BiCheck, BiPencil, BiTrash, BiX } from "react-icons/bi"; -import React from "react"; -import { useForm } from "@mantine/form"; -import TippedActionIcon from "../../common/TippedActionIcon"; -import NavbarConversationInfo from "./NavbarConversationInfo"; -import { setConversationName } from "../../../store/actions/conversations/setConversationName"; -import { removeConversation } from "../../../store/actions/conversations/removeConversation"; -import { setActiveConversation } from "../../../store/actions/conversations/setActiveConversation"; -import { useActiveConversation } from "../../../store/hooks/conversations/useActiveConversation"; -import { useGetConversationName } from "../../../store/hooks/conversations/useGetConversationName"; -import { DEFAULT_CONVERSATION_NAME } from "../../../config/constants"; - -interface NavbarConversationProps { - conversation: Conversation; - onClick?: () => void; -} - -const useStyles = createStyles((theme, { isActive }: { isActive: boolean }) => { - const dark = theme.colorScheme === "dark"; - let backgroundColor: string | undefined; - let hoverBackgroundColor: string | undefined; - let color: string | undefined; - - if (dark && isActive) { - backgroundColor = theme.colors.blue[7]; - hoverBackgroundColor = theme.colors.blue[8]; - color = theme.white; - } else if (dark && !isActive) { - backgroundColor = undefined; - hoverBackgroundColor = theme.colors.dark[6]; - color = theme.white; - } else if (isActive) { - backgroundColor = theme.colors.blue[1]; - hoverBackgroundColor = theme.colors.blue[2]; - color = theme.colors.blue[7]; - } else { - backgroundColor = undefined; - hoverBackgroundColor = theme.colors.gray[1]; - color = theme.colors.gray[8]; - } - - return { - root: { - display: "flex", - alignItems: "center", - textDecoration: "none", - fontWeight: 600, - borderRadius: theme.radius.sm, - backgroundColor, - color, - - "&:hover": { - textDecoration: "none", - backgroundColor: hoverBackgroundColor, - }, - }, - }; -}); - -const NavbarConversation = ({ - conversation, - onClick, -}: NavbarConversationProps) => { - const activeConversation = useActiveConversation(); - const getConversationName = useGetConversationName(); - const [isDeleting, setIsDeleting] = React.useState(false); - const [isEditing, setIsEditing] = React.useState(false); - const isActive = conversation.id === activeConversation?.id; - const { classes } = useStyles({ isActive }); - - const editFormRef = React.useRef(null); - const editForm = useForm({ - initialValues: { - name: - getConversationName(conversation.id) ?? - DEFAULT_CONVERSATION_NAME, - }, - }); - - const name = React.useMemo(() => { - return ( - getConversationName(conversation.id) ?? DEFAULT_CONVERSATION_NAME - ); - }, [conversation.id, getConversationName]); - - const onEdit = editForm.onSubmit((values) => { - if (values.name && values.name !== name) { - setConversationName(conversation.id, values.name); - } - setIsEditing(false); - }); - - const onDelete = React.useCallback(() => { - removeConversation(conversation.id); - setIsDeleting(false); - }, [conversation.id]); - - const onCancel = React.useCallback(() => { - setIsDeleting(false); - setIsEditing(false); - }, []); - - React.useEffect(() => { - if (!isActive) { - setIsDeleting(false); - setIsEditing(false); - } - }, [isActive]); - - const CancelAction = React.useMemo( - () => ( - { - e.stopPropagation(); - onCancel(); - }} - > - - - ), - [onCancel] - ); - - const Actions = React.useMemo(() => { - if (isDeleting) { - return ( - <> - { - e.stopPropagation(); - onDelete(); - }} - > - - - {CancelAction} - - ); - } - - if (isEditing) { - return ( - <> - { - e.stopPropagation(); - editFormRef.current?.requestSubmit(); - }} - > - - - {CancelAction} - - ); - } - - return ( - <> - { - e.stopPropagation(); - setIsEditing(true); - }} - > - - - { - e.stopPropagation(); - setIsDeleting(true); - }} - > - - - - ); - }, [CancelAction, isDeleting, isEditing, onDelete]); - - return ( - { - if (isEditing) return; - setActiveConversation(conversation.id); - onClick?.(); - }} - > - - - {isEditing ? ( -
- - - ) : ( - - {name} - - )} - {!isEditing && ( - - )} -
- {isActive && ( - - {Actions} - - )} -
-
- ); -}; - -export default NavbarConversation; diff --git a/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversation.tsx b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversation.tsx new file mode 100644 index 0000000..9b9abcb --- /dev/null +++ b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversation.tsx @@ -0,0 +1,99 @@ +import { Anchor, Box, Group, Stack, Text, createStyles } from "@mantine/core"; +import { Conversation } from "gpt-turbo"; +import React from "react"; +import NavbarConversationInfo from "../NavbarConversationInfo"; +import { setActiveConversation } from "../../../../store/actions/conversations/setActiveConversation"; +import { useActiveConversation } from "../../../../store/hooks/conversations/useActiveConversation"; +import { useGetConversationName } from "../../../../store/hooks/conversations/useGetConversationName"; +import { DEFAULT_CONVERSATION_NAME } from "../../../../config/constants"; +import NavbarConversationMenu from "./NavbarConversationMenu"; + +interface NavbarConversationProps { + conversation: Conversation; + onClick?: () => void; +} + +const useStyles = createStyles((theme, { isActive }: { isActive: boolean }) => { + const dark = theme.colorScheme === "dark"; + let backgroundColor: string | undefined; + let hoverBackgroundColor: string | undefined; + let color: string | undefined; + + if (dark && isActive) { + backgroundColor = theme.colors.blue[7]; + hoverBackgroundColor = theme.colors.blue[8]; + color = theme.white; + } else if (dark && !isActive) { + backgroundColor = undefined; + hoverBackgroundColor = theme.colors.dark[6]; + color = theme.white; + } else if (isActive) { + backgroundColor = theme.colors.blue[1]; + hoverBackgroundColor = theme.colors.blue[2]; + color = theme.colors.blue[7]; + } else { + backgroundColor = undefined; + hoverBackgroundColor = theme.colors.gray[1]; + color = theme.colors.gray[8]; + } + + return { + root: { + display: "flex", + alignItems: "center", + textDecoration: "none", + fontWeight: 600, + borderRadius: theme.radius.sm, + backgroundColor, + color, + + "&:hover": { + textDecoration: "none", + backgroundColor: hoverBackgroundColor, + }, + }, + }; +}); + +const NavbarConversation = ({ + conversation, + onClick, +}: NavbarConversationProps) => { + const activeConversation = useActiveConversation(); + const getConversationName = useGetConversationName(); + const isActive = conversation.id === activeConversation?.id; + const { classes } = useStyles({ isActive }); + + const name = React.useMemo(() => { + return ( + getConversationName(conversation.id) ?? DEFAULT_CONVERSATION_NAME + ); + }, [conversation.id, getConversationName]); + + return ( + { + setActiveConversation(conversation.id); + onClick?.(); + }} + > + + {/* FIXME: max-width: 83% is fine in most cases, but not perfect on mobile */} + + + {name} + + + + e.stopPropagation()}> + + + + + ); +}; + +export default NavbarConversation; diff --git a/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversationMenu.tsx b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversationMenu.tsx new file mode 100644 index 0000000..e71f190 --- /dev/null +++ b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversation/NavbarConversationMenu.tsx @@ -0,0 +1,181 @@ +import { Menu, ActionIcon, Modal } from "@mantine/core"; +import { + BiCog, + BiDotsVerticalRounded, + BiDuplicate, + BiEdit, + BiExport, + BiTrash, +} from "react-icons/bi"; +import { useDisclosure } from "@mantine/hooks"; +import ConversationNameEditModal, { + ConversationNameEditModalProps, +} from "../../../modals/ConversationNameEditModal"; +import { useAppStore } from "../../../../store"; +import React from "react"; +import { setConversationName } from "../../../../store/actions/conversations/setConversationName"; +import { useGetConversationName } from "../../../../store/hooks/conversations/useGetConversationName"; +import { DEFAULT_CONVERSATION_NAME } from "../../../../config/constants"; +import ConversationForm from "../../../forms/ConversationForm/ConversationForm"; +import { ConversationFormProviderProps } from "../../../../contexts/providers/ConversationFormProvider"; +import { editConversation } from "../../../../store/actions/conversations/editConversation"; +import { modals } from "@mantine/modals"; +import { duplicateConversation } from "../../../../store/actions/conversations/duplicateConversation"; +import { removeConversation } from "../../../../store/actions/conversations/removeConversation"; +import { + ConversationExport, + conversationExportSchema, +} from "../../../../entities/conversationExport"; + +interface NavbarConversationMenuProps { + conversationId: string; +} + +const NavbarConversationMenu = ({ + conversationId, +}: NavbarConversationMenuProps) => { + const conversation = useAppStore((state) => + state.conversations.find((c) => c.id === conversationId) + ); + const getConversationName = useGetConversationName(); + + const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(); + const onEditName = React.useCallback< + ConversationNameEditModalProps["onSubmit"] + >((name) => setConversationName(conversationId, name), [conversationId]); + + const [settingsOpened, { open: openSettings, close: closeSettings }] = + useDisclosure(); + const onEditSettings = React.useCallback< + ConversationFormProviderProps["onSubmit"] + >( + (values) => { + editConversation(conversationId, values); + closeSettings(); + }, + [closeSettings, conversationId] + ); + + const onDuplicate = React.useCallback(() => { + const name = getConversationName(conversationId); + modals.openConfirmModal({ + title: `Duplicate "${name ?? DEFAULT_CONVERSATION_NAME}"?`, + centered: true, + labels: { + confirm: "Duplicate", + cancel: "Cancel", + }, + onConfirm: () => { + duplicateConversation(conversationId); + }, + children: + "This will create a copy of the conversation with the same settings and messages.", + }); + }, [conversationId, getConversationName]); + + const onExport = React.useCallback(() => { + if (!conversation) return; + const name = getConversationName(conversationId); + const data = JSON.stringify( + conversationExportSchema.parse({ + conversation: conversation.toJSON(), + name: name ?? DEFAULT_CONVERSATION_NAME, + } satisfies ConversationExport) + ); + const blob = new Blob([data], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `gptturbo-conversation-${conversation.id}.json`; + a.click(); + URL.revokeObjectURL(url); + }, [conversation, conversationId, getConversationName]); + + const onDelete = React.useCallback(() => { + const name = getConversationName(conversationId); + modals.openConfirmModal({ + title: `Delete "${name ?? DEFAULT_CONVERSATION_NAME}"?`, + centered: true, + labels: { + confirm: "Delete", + cancel: "Cancel", + }, + confirmProps: { + color: "red", + }, + onConfirm: () => { + removeConversation(conversationId); + }, + children: "This cannot be undone.", + }); + }, [conversationId, getConversationName]); + + if (!conversation) { + return null; + } + + return ( + <> + + + + + + + + + } + color="blue" + onClick={openEdit} + > + Edit Name + + } onClick={openSettings}> + Settings + + } onClick={onDuplicate}> + Duplicate + + } onClick={onExport}> + Export + + } + color="red" + onClick={onDelete} + > + Delete + + + + + + + + + + + ); +}; + +export default NavbarConversationMenu; diff --git a/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversations.tsx b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversations.tsx index 0337b9e..a90971c 100644 --- a/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversations.tsx +++ b/packages/web/src/components/ConversationNavbar/NavbarConversations/NavbarConversations.tsx @@ -1,12 +1,5 @@ -import { - Divider, - Group, - ScrollArea, - Stack, - Text, - createStyles, -} from "@mantine/core"; -import NavbarConversation from "./NavbarConversation"; +import { Divider, Group, Stack, Text } from "@mantine/core"; +import NavbarConversation from "./NavbarConversation/NavbarConversation"; import React from "react"; import TippedActionIcon from "../../common/TippedActionIcon"; import { BiTrash } from "react-icons/bi"; @@ -19,14 +12,6 @@ interface NavbarConversationsProps { onConversationSelect?: () => void; } -const useStyles = createStyles(() => ({ - scrollArea: { - "& > div": { - display: "block !important", - }, - }, -})); - const getRelativeDate = (target: number) => { const currentDate = new Date(); const targetDate = new Date(target); @@ -75,7 +60,6 @@ const getRelativeDate = (target: number) => { const NavbarConversations = ({ onConversationSelect = () => {}, }: NavbarConversationsProps) => { - const { classes } = useStyles(); const conversations = useAppStore((state) => state.conversations); const getConversationLastEdit = useGetConversationLastEdit(); const [deleteConfirmation, setdeleteConfirmation] = React.useState< @@ -125,62 +109,51 @@ const NavbarConversations = ({ ); return ( - - - {Object.entries(conversationGroups).map( - ([relativeDate, conversations]) => ( - - - - {relativeDate} -
- } - /> - - - - - {conversations.map((conversation) => ( - - ))} - - ) - )} - - + + {Object.entries(conversationGroups).map( + ([relativeDate, conversations]) => ( + + + + {relativeDate} + + } + /> + + + + + {conversations.map((conversation) => ( + + ))} + + ) + )} + ); }; diff --git a/packages/web/src/components/NavbarConversations/NavbarConversation.tsx b/packages/web/src/components/NavbarConversations/NavbarConversation.tsx deleted file mode 100644 index fd7dbb6..0000000 --- a/packages/web/src/components/NavbarConversations/NavbarConversation.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { - Anchor, - Group, - Stack, - Text, - TextInput, - createStyles, -} from "@mantine/core"; -import { Conversation } from "gpt-turbo"; -import { BiCheck, BiPencil, BiTrash, BiX } from "react-icons/bi"; -import React from "react"; -import { useForm } from "@mantine/form"; -import TippedActionIcon from "../common/TippedActionIcon"; -import { setConversationName } from "../../store/actions/conversations/setConversationName"; -import { removeConversation } from "../../store/actions/conversations/removeConversation"; -import { setActiveConversation } from "../../store/actions/conversations/setActiveConversation"; -import NavbarConversationInfo from "../ConversationNavbar/NavbarConversations/NavbarConversationInfo"; -import { useActiveConversation } from "../../store/hooks/conversations/useActiveConversation"; -import { useGetConversationName } from "../../store/hooks/conversations/useGetConversationName"; -import { DEFAULT_CONVERSATION_NAME } from "../../config/constants"; - -interface NavbarConversationProps { - conversation: Conversation; - onClick?: () => void; -} - -const useStyles = createStyles((theme, { isActive }: { isActive: boolean }) => { - const dark = theme.colorScheme === "dark"; - let backgroundColor: string | undefined; - let hoverBackgroundColor: string | undefined; - let color: string | undefined; - - if (dark && isActive) { - backgroundColor = theme.colors.blue[7]; - hoverBackgroundColor = theme.colors.blue[8]; - color = theme.white; - } else if (dark && !isActive) { - backgroundColor = undefined; - hoverBackgroundColor = theme.colors.dark[6]; - color = theme.white; - } else if (isActive) { - backgroundColor = theme.colors.blue[1]; - hoverBackgroundColor = theme.colors.blue[2]; - color = theme.colors.blue[7]; - } else { - backgroundColor = undefined; - hoverBackgroundColor = theme.colors.gray[1]; - color = theme.colors.gray[8]; - } - - return { - root: { - display: "flex", - alignItems: "center", - textDecoration: "none", - fontWeight: 600, - borderRadius: theme.radius.sm, - backgroundColor, - color, - - "&:hover": { - textDecoration: "none", - backgroundColor: hoverBackgroundColor, - }, - }, - }; -}); - -const NavbarConversation = ({ - conversation, - onClick, -}: NavbarConversationProps) => { - const activeConversation = useActiveConversation(); - const getConversationName = useGetConversationName(); - const [isDeleting, setIsDeleting] = React.useState(false); - const [isEditing, setIsEditing] = React.useState(false); - const isActive = conversation.id === activeConversation?.id; - const { classes } = useStyles({ isActive }); - - const editFormRef = React.useRef(null); - const editForm = useForm({ - initialValues: { - name: - getConversationName(conversation.id) ?? - DEFAULT_CONVERSATION_NAME, - }, - }); - - const name = React.useMemo(() => { - return ( - getConversationName(conversation.id) ?? DEFAULT_CONVERSATION_NAME - ); - }, [conversation.id, getConversationName]); - - const onEdit = editForm.onSubmit((values) => { - if (values.name && values.name !== name) { - setConversationName(conversation.id, values.name); - } - setIsEditing(false); - }); - - const onDelete = React.useCallback(() => { - removeConversation(conversation.id); - setIsDeleting(false); - }, [conversation.id]); - - const onCancel = React.useCallback(() => { - setIsDeleting(false); - setIsEditing(false); - }, []); - - React.useEffect(() => { - if (!isActive) { - setIsDeleting(false); - setIsEditing(false); - } - }, [isActive]); - - const CancelAction = React.useMemo( - () => ( - { - e.stopPropagation(); - onCancel(); - }} - > - - - ), - [onCancel] - ); - - const Actions = React.useMemo(() => { - if (isDeleting) { - return ( - <> - { - e.stopPropagation(); - onDelete(); - }} - > - - - {CancelAction} - - ); - } - - if (isEditing) { - return ( - <> - { - e.stopPropagation(); - editFormRef.current?.requestSubmit(); - }} - > - - - {CancelAction} - - ); - } - - return ( - <> - { - e.stopPropagation(); - setIsEditing(true); - }} - > - - - { - e.stopPropagation(); - setIsDeleting(true); - }} - > - - - - ); - }, [CancelAction, isDeleting, isEditing, onDelete]); - - return ( - { - if (isEditing) return; - setActiveConversation(conversation.id); - onClick?.(); - }} - > - - - {isEditing ? ( -
- - - ) : ( - - {name} - - )} - {!isEditing && ( - - )} -
- {isActive && ( - - {Actions} - - )} -
-
- ); -}; - -export default NavbarConversation; diff --git a/packages/web/src/components/forms/CallableFunctionParameterForm/CallableFunctionParameterForm.tsx b/packages/web/src/components/forms/CallableFunctionParameterForm/CallableFunctionParameterForm.tsx index e8637bd..4873676 100644 --- a/packages/web/src/components/forms/CallableFunctionParameterForm/CallableFunctionParameterForm.tsx +++ b/packages/web/src/components/forms/CallableFunctionParameterForm/CallableFunctionParameterForm.tsx @@ -54,7 +54,7 @@ export interface CallableFunctionParameterFormProps { const callableFunctionParameterFormSchema = jsonSchemaBaseSchema.extend({ name: z .string() - .min(1) + .nonempty("Name is required") .regex(/^[a-zA-Z0-9_$]+$/, "Must be a valid JavaScript identifier"), jsonSchema: z.string().refine((v) => { try { diff --git a/packages/web/src/components/forms/ConversationForm/ConversationForm.tsx b/packages/web/src/components/forms/ConversationForm/ConversationForm.tsx index b01153a..a5f7888 100644 --- a/packages/web/src/components/forms/ConversationForm/ConversationForm.tsx +++ b/packages/web/src/components/forms/ConversationForm/ConversationForm.tsx @@ -1,30 +1,53 @@ -import { Stack, Text, Button, Tabs, Box } from "@mantine/core"; +import { Stack, Text, Button, Tabs, Box, Group } from "@mantine/core"; import ConversationFormProvider, { ConversationFormProviderProps, } from "../../../contexts/providers/ConversationFormProvider"; import ConversationFormConversationTab from "./ConversationFormConversationTab"; +import ConversationFormAdvancedTab from "./ConversationFormAdvancedTab"; import ConversationFormRequestTab from "./ConversationFormRequestTab"; import ConversationFormFunctionsTab from "./ConversationFormFunctionsTab"; -import AppSettings from "../../AppSettings/AppSettings"; import React from "react"; import useConversationForm from "../../../contexts/hooks/useConversationForm"; +import TippedActionIcon from "../../common/TippedActionIcon"; +import { BiSave } from "react-icons/bi"; +import { useAppStore } from "../../../store"; +import { setDefaultSettings } from "../../../store/actions/defaultConversationSettings/setDefaultSettings"; +import { notifications } from "@mantine/notifications"; interface ConversationFormProvidedProps { - hideAppSettings?: boolean; + subimitLabel?: string; } -type ConversationFormProps = ConversationFormProvidedProps & { - onSubmit: ConversationFormProviderProps["onSubmit"]; -}; +type ConversationFormProps = ConversationFormProvidedProps & + Omit; -type ConversationFormTab = "conversation" | "request" | "functions" | "app"; +type ConversationFormTab = + | "conversation" + | "advanced" + | "request" + | "functions"; const ConversationFormProvided = ({ - hideAppSettings = false, + subimitLabel = "Create Conversation", }: ConversationFormProvidedProps) => { const form = useConversationForm(); const [currentTab, setCurrentTab] = React.useState("conversation"); + const settings = useAppStore((state) => state.defaultSettings); + + const onSetDefaultSettings = React.useCallback(() => { + const { hasErrors } = form.validate(); + if (hasErrors) return; + + setDefaultSettings({ + ...settings, + ...form.getTransformedValues(), + }); + notifications.show({ + message: "Default settings saved", + color: "green", + }); + }, [form, settings]); return ( @@ -39,50 +62,59 @@ const ConversationFormProvided = ({ > Conversation - Request + Advanced Functions - {!hideAppSettings && ( - App Settings - )} + Request - - + + - {!hideAppSettings && ( - - - - )} + + + - {currentTab !== "app" && ( - + + - {form.values.save && ( - - This conversation will be saved to your browser's - local storage. Make sure the device you're using is - trusted and not shared with anyone else. - - )} - - )} + + + + + {form.values.save && ( + + This conversation will be saved to your browser's local + storage. Make sure the device you're using is trusted + and not shared with anyone else. + + )} + ); }; const ConversationForm = ({ onSubmit, + conversationId, ...conversationFormProvidedProps }: ConversationFormProps) => ( - + ); diff --git a/packages/web/src/components/forms/ConversationForm/ConversationFormAdvancedTab.tsx b/packages/web/src/components/forms/ConversationForm/ConversationFormAdvancedTab.tsx new file mode 100644 index 0000000..666d633 --- /dev/null +++ b/packages/web/src/components/forms/ConversationForm/ConversationFormAdvancedTab.tsx @@ -0,0 +1,68 @@ +import { Box, Grid, TextInput } from "@mantine/core"; +import useConversationForm from "../../../contexts/hooks/useConversationForm"; +import LogitBiasInput from "../../inputs/LogitBiasInput"; +import OptionalNumberInput from "../../inputs/OptionalNumberInput"; +import StopInput from "../../inputs/StopInput"; + +const ConversationFormAdvancedTab = () => { + const form = useConversationForm(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ConversationFormAdvancedTab; diff --git a/packages/web/src/components/forms/ConversationForm/ConversationFormConversationTab.tsx b/packages/web/src/components/forms/ConversationForm/ConversationFormConversationTab.tsx index 6f8fb1d..8b7a455 100644 --- a/packages/web/src/components/forms/ConversationForm/ConversationFormConversationTab.tsx +++ b/packages/web/src/components/forms/ConversationForm/ConversationFormConversationTab.tsx @@ -1,28 +1,15 @@ -import { - Stack, - Group, - Divider, - Button, - Collapse, - Grid, - TextInput, -} from "@mantine/core"; +import { Stack, Group } from "@mantine/core"; import ApiKeyInput from "../../inputs/ApiKeyInput"; import ContextInput from "../../inputs/ContextInput"; import DisableModerationInput from "../../inputs/DisableModerationInput"; import DryInput from "../../inputs/DryInput"; -import LogitBiasInput from "../../inputs/LogitBiasInput"; import ModelSelectInput from "../../inputs/ModelSelectInput/ModelSelectInput"; -import OptionalNumberInput from "../../inputs/OptionalNumberInput"; import SaveInput from "../../inputs/SaveInput"; -import StopInput from "../../inputs/StopInput"; import StreamInput from "../../inputs/StreamInput"; -import React from "react"; import useConversationForm from "../../../contexts/hooks/useConversationForm"; const ConversationFormConversationTab = () => { const form = useConversationForm(); - const [showAdvanced, setShowAdvanced] = React.useState(false); return ( @@ -45,72 +32,6 @@ const ConversationFormConversationTab = () => { - setShowAdvanced(!showAdvanced)} - w={200} - > - {showAdvanced ? "Hide" : "Show"} Advanced Settings - - } - /> - - - - - - - - - - - - - - - - - - - - - - - - - - ); }; diff --git a/packages/web/src/components/inputs/ConversationDropzone.tsx b/packages/web/src/components/inputs/ConversationDropzone.tsx new file mode 100644 index 0000000..4dafe48 --- /dev/null +++ b/packages/web/src/components/inputs/ConversationDropzone.tsx @@ -0,0 +1,147 @@ +import { + Box, + Center, + ScrollArea, + Stack, + Text, + createStyles, + useMantineTheme, +} from "@mantine/core"; +import { Dropzone, DropzoneProps } from "@mantine/dropzone"; +import React from "react"; +import { BiUpload, BiX } from "react-icons/bi"; +import { + ConversationExport, + conversationExportSchema, +} from "../../entities/conversationExport"; +import { notifications } from "@mantine/notifications"; +import readJsonFile from "../../utils/readJsonFile"; +import { useId } from "@mantine/hooks"; + +interface ConversationNavbarDropzoneBaseProps { + onDrop: (conversations: ConversationExport[]) => void; + children?: React.ReactNode; +} + +export type ConversationNavbarDropzoneProps = Omit< + DropzoneProps, + keyof ConversationNavbarDropzoneBaseProps +> & + ConversationNavbarDropzoneBaseProps; + +const useStyles = createStyles((_, { childrenId }: { childrenId: string }) => ({ + dropzone: { + height: "100%", + cursor: "default", + padding: 0, + border: "none", + + "&[data-idle]": { + backgroundColor: "transparent", + }, + + "&[data-accept]": { + [`& .${childrenId}`]: { + opacity: 0, + }, + }, + + "& .mantine-Dropzone-inner": { + pointerEvents: "all", + }, + }, + scrollArea: { + height: "100%", + + "& .mantine-ScrollArea-viewport > div": { + height: "100%", + }, + }, +})); + +const ConversationDropzone = ({ + onDrop, + children, + ...dropzoneProps +}: ConversationNavbarDropzoneProps) => { + const theme = useMantineTheme(); + const childrenId = useId(); + const { classes } = useStyles({ childrenId }); + + const handleDrop = React.useCallback( + async (files: File[]) => { + const imported = await Promise.all( + files.map(async (file) => { + try { + return await readJsonFile( + file, + conversationExportSchema + ); + } catch (e) { + notifications.show({ + title: "Failed to import conversation", + message: "Not a valid conversation file", + color: "red", + icon: , + }); + } + }) + ); + const conversations = imported.filter( + (fn): fn is ConversationExport => fn !== undefined + ); + onDrop(conversations); + }, + [onDrop] + ); + + return ( + + + +
+ + + Import Conversation(s) + +
+
+ + +
+ + + Not a JSON file + +
+
+ + {children} +
+
+ ); +}; + +export default ConversationDropzone; diff --git a/packages/web/src/components/modals/ConversationNameEditModal.tsx b/packages/web/src/components/modals/ConversationNameEditModal.tsx new file mode 100644 index 0000000..592e3fd --- /dev/null +++ b/packages/web/src/components/modals/ConversationNameEditModal.tsx @@ -0,0 +1,105 @@ +import { + Button, + Group, + Modal, + ModalProps, + Stack, + TextInput, + useMantineTheme, +} from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { z } from "zod"; +import { HiSparkles } from "react-icons/hi"; +import { persistenceConversationSchema } from "../../entities/persistenceConversation"; +import TippedActionIcon from "../common/TippedActionIcon"; +import React from "react"; +import { useGenerateConversationName } from "../../store/hooks/conversations/useGenerateConversationName"; +import { notifications } from "@mantine/notifications"; + +interface ConversationNameEditModalBaseProps { + onSubmit: (name: z.infer["name"]) => void; + conversationId: string; + initialName?: z.infer["name"]; +} + +export type ConversationNameEditModalProps = Omit & + ConversationNameEditModalBaseProps; + +const schema = z.object({ + name: persistenceConversationSchema.shape.name, +}); + +const ConversationNameEditModal = ({ + onSubmit, + initialName = "", + conversationId, + ...modalProps +}: ConversationNameEditModalProps) => { + const theme = useMantineTheme(); + const { canGenerate, isGenerating, generateConversationName } = + useGenerateConversationName(conversationId); + const form = useForm({ + initialValues: { + name: initialName, + }, + validate: zodResolver(schema), + transformValues: schema.parse, + }); + + const handleSubmit = form.onSubmit(({ name }) => { + onSubmit(name); + modalProps.onClose(); + }); + + const onGenerate = React.useCallback(async () => { + const name = await generateConversationName(); + if (!name) { + notifications.show({ + title: "Failed to generate name", + message: + "This may not be an error. The assistant may not be able to generate a name for this conversation.", + color: "yellow", + }); + return; + } + form.setFieldValue("name", name); + }, [form, generateConversationName]); + + return ( + +
+ + + + + ) + } + {...form.getInputProps("name")} + /> + + + + + +
+
+ ); +}; + +export default ConversationNameEditModal; diff --git a/packages/web/src/components/modals/SettingsFormModal.tsx b/packages/web/src/components/modals/SettingsFormModal.tsx deleted file mode 100644 index 07195c0..0000000 --- a/packages/web/src/components/modals/SettingsFormModal.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Container, Modal, ModalProps, useMantineTheme } from "@mantine/core"; -import { useMediaQuery } from "@mantine/hooks"; -import React from "react"; -import { ConversationFormValues } from "../../contexts/ConversationFormContext"; -import ConversationForm from "../forms/ConversationForm/ConversationForm"; -import { useAppStore } from "../../store"; -import { setDefaultSettings } from "../../store/actions/defaultConversationSettings/setDefaultSettings"; - -type SettingsFormModalProps = ModalProps; - -const SettingsFormModal = ({ - onClose, - ...modalProps -}: SettingsFormModalProps) => { - const theme = useMantineTheme(); - const isSm = useMediaQuery(`(max-width: ${theme.breakpoints.md})`); - const settings = useAppStore((state) => state.defaultSettings); - - const onSubmit = React.useCallback( - (values: ConversationFormValues) => { - setDefaultSettings({ - ...settings, - ...values, - }); - onClose(); - }, - [onClose, settings] - ); - - return ( - - - - - - ); -}; - -export default SettingsFormModal; diff --git a/packages/web/src/contexts/providers/ConversationFormProvider.tsx b/packages/web/src/contexts/providers/ConversationFormProvider.tsx index d2c5df9..3842f1d 100644 --- a/packages/web/src/contexts/providers/ConversationFormProvider.tsx +++ b/packages/web/src/contexts/providers/ConversationFormProvider.tsx @@ -4,17 +4,30 @@ import { ConversationFormValues, } from "../ConversationFormContext"; import { useAppStore } from "../../store"; +import { shallow } from "zustand/shallow"; export interface ConversationFormProviderProps { children?: React.ReactNode; onSubmit: (values: ConversationFormValues) => void | Promise; + conversationId?: string; } const ConversationFormProvider = ({ children, onSubmit, + conversationId, }: ConversationFormProviderProps) => { - const settings = useAppStore((state) => state.defaultSettings); + const [settings, persistedConversationIds, conversation] = useAppStore( + (state) => [ + state.defaultSettings, + state.persistedConversationIds, + conversationId + ? state.conversations.find((c) => c.id === conversationId) + : undefined, + ], + shallow + ); + const form = ConversationFormContext.useForm({ initialValues: { save: settings.save, @@ -51,6 +64,44 @@ const ConversationFormProvider = ({ await onSubmit(values); }); + const hasEditInit = React.useRef(false); + React.useEffect(() => { + if (!conversation || form.isDirty() || hasEditInit.current) return; + hasEditInit.current = true; + const { + config = {}, + functions = [], + requestOptions = {}, + } = conversation.toJSON(); + + form.setValues({ + save: persistedConversationIds.includes(conversation.id), + apiKey: config.apiKey, + + model: config.model, + context: config.context, + dry: config.dry, + disableModeration: config.disableModeration, + stream: config.stream, + + temperature: config.temperature, + top_p: config.top_p, + frequency_penalty: config.frequency_penalty, + presence_penalty: config.presence_penalty, + stop: config.stop, + max_tokens: config.max_tokens, + logit_bias: config.logit_bias, + user: config.user, + + functionIds: functions + .map((f) => f.id) + .filter((id): id is string => !!id), + + headers: requestOptions.headers, + proxy: requestOptions.proxy, + }); + }, [conversation, form, persistedConversationIds]); + return (
diff --git a/packages/web/src/entities/conversationExport.ts b/packages/web/src/entities/conversationExport.ts new file mode 100644 index 0000000..b91f065 --- /dev/null +++ b/packages/web/src/entities/conversationExport.ts @@ -0,0 +1,18 @@ +import { conversationSchema } from "gpt-turbo"; +import { z } from "zod"; + +export const conversationExportSchema = z.object({ + conversation: conversationSchema + .extend({ + config: conversationSchema.shape.config + .unwrap() + .omit({ apiKey: true }) + .optional(), + }) + .omit({ + id: true, + }), + name: z.string().nonempty("Conversation name cannot be empty"), +}); + +export type ConversationExport = z.infer; diff --git a/packages/web/src/entities/persistenceAppSettings.ts b/packages/web/src/entities/persistenceAppSettings.ts index 7d0d515..7ab94b3 100644 --- a/packages/web/src/entities/persistenceAppSettings.ts +++ b/packages/web/src/entities/persistenceAppSettings.ts @@ -6,6 +6,7 @@ export const persistenceAppSettingsSchema = z.object({ .union([z.literal("light"), z.literal("dark")]) .default("light"), lastChangelog: z.string().default(""), + showConversationImport: z.boolean().default(true), }); export type PersistenceAppSettings = z.infer< diff --git a/packages/web/src/entities/persistenceCallableFunction.ts b/packages/web/src/entities/persistenceCallableFunction.ts index df39ff5..e8db1f7 100644 --- a/packages/web/src/entities/persistenceCallableFunction.ts +++ b/packages/web/src/entities/persistenceCallableFunction.ts @@ -2,7 +2,7 @@ import { callableFunctionSchema } from "gpt-turbo"; import { z } from "zod"; export const persistenceCallableFunctionSchema = callableFunctionSchema.extend({ - displayName: z.string().min(1), + displayName: z.string().nonempty("Function display name cannot be empty"), code: z.string().optional(), }); diff --git a/packages/web/src/entities/persistenceConversation.ts b/packages/web/src/entities/persistenceConversation.ts index 80e2650..7b7b424 100644 --- a/packages/web/src/entities/persistenceConversation.ts +++ b/packages/web/src/entities/persistenceConversation.ts @@ -7,7 +7,7 @@ export const persistenceConversationSchema = conversationSchema.extend({ .omit({ apiKey: true }) .optional(), - name: z.string(), + name: z.string().nonempty("Conversation name cannot be empty"), lastEdited: z.number(), }); diff --git a/packages/web/src/entities/persistenceSavedContext.ts b/packages/web/src/entities/persistenceSavedContext.ts index 6050460..64a5cb5 100644 --- a/packages/web/src/entities/persistenceSavedContext.ts +++ b/packages/web/src/entities/persistenceSavedContext.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const persistenceSavedContextSchema = z.object({ - name: z.string().max(50).min(1), + name: z.string().max(50).nonempty("Context name cannot be empty"), value: z.string(), }); diff --git a/packages/web/src/entities/persistenceSavedPrompt.ts b/packages/web/src/entities/persistenceSavedPrompt.ts index 6500a03..ede41a9 100644 --- a/packages/web/src/entities/persistenceSavedPrompt.ts +++ b/packages/web/src/entities/persistenceSavedPrompt.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const persistenceSavedPromptSchema = z.object({ - name: z.string().max(50).min(1), + name: z.string().max(50).nonempty("Prompt name cannot be empty"), value: z.string(), }); diff --git a/packages/web/src/store/actions/appSettings/toggleShowConversationImport.ts b/packages/web/src/store/actions/appSettings/toggleShowConversationImport.ts new file mode 100644 index 0000000..1d2f500 --- /dev/null +++ b/packages/web/src/store/actions/appSettings/toggleShowConversationImport.ts @@ -0,0 +1,11 @@ +import { createAction } from "../createAction"; + +export const toggleShowConversationImport = createAction( + ({ set }, showConversationImport?: boolean) => { + set((state) => { + state.showConversationImport = + showConversationImport ?? !state.showConversationImport; + }); + }, + "toggleShowConversationImport" +); diff --git a/packages/web/src/store/actions/conversations/addConversation.ts b/packages/web/src/store/actions/conversations/addConversation.ts index ff741bf..9cc341c 100644 --- a/packages/web/src/store/actions/conversations/addConversation.ts +++ b/packages/web/src/store/actions/conversations/addConversation.ts @@ -4,23 +4,44 @@ import { RequestOptions, } from "gpt-turbo"; import { createAction } from "../createAction"; +import { addPersistedConversationId } from "../persistence/addPersistedConversationId"; export const addConversation = createAction( ( - { set }, + { set, get }, conversation: Conversation | ConversationConfigParameters, - requestOptions?: RequestOptions + requestOptions?: RequestOptions, + functionIds: string[] = [], + save = false ) => { + const { callableFunctions } = get(); const newConversation = conversation instanceof Conversation ? conversation : new Conversation(conversation, requestOptions); + for (const functionId of functionIds) { + const callableFunction = callableFunctions.find( + (callableFunction) => callableFunction.id === functionId + ); + if (callableFunction) { + newConversation.addFunction(callableFunction); + } + } + set((state) => { - state.conversations.push(newConversation); + state.conversations = state.conversations + .filter( + (conversation) => conversation.id !== newConversation.id + ) + .concat(newConversation); state.conversationLastEdits.set(newConversation.id, Date.now()); }); + if (save) { + addPersistedConversationId(newConversation.id); + } + return newConversation; }, "addConversation" diff --git a/packages/web/src/store/actions/conversations/duplicateConversation.ts b/packages/web/src/store/actions/conversations/duplicateConversation.ts new file mode 100644 index 0000000..214c141 --- /dev/null +++ b/packages/web/src/store/actions/conversations/duplicateConversation.ts @@ -0,0 +1,32 @@ +import { Conversation } from "gpt-turbo"; +import { createAction } from "../createAction"; +import { addConversation } from "./addConversation"; +import { setConversationName } from "./setConversationName"; + +export const duplicateConversation = createAction( + async ({ get }, id: string) => { + const { conversations, persistedConversationIds, conversationNames } = + get(); + const conversation = conversations.find((c) => c.id === id); + + if (!conversation) { + throw new Error(`Conversation with id ${id} not found`); + } + + const { id: _, ...json } = conversation.toJSON(); + const copy = await Conversation.fromJSON(json); + + const newConversation = addConversation( + copy, + undefined, + undefined, + persistedConversationIds.includes(id) + ); + + const name = conversationNames.get(id); + if (name) { + setConversationName(newConversation.id, name); + } + }, + "duplicateConversation" +); diff --git a/packages/web/src/store/actions/conversations/editConversation.ts b/packages/web/src/store/actions/conversations/editConversation.ts new file mode 100644 index 0000000..dc2d495 --- /dev/null +++ b/packages/web/src/store/actions/conversations/editConversation.ts @@ -0,0 +1,57 @@ +import { ConversationFormValues } from "../../../contexts/ConversationFormContext"; +import { createAction } from "../createAction"; +import { addPersistedConversationId } from "../persistence/addPersistedConversationId"; +import { removePersistedConversationId } from "../persistence/removePersistedConversationId"; + +export const editConversation = createAction( + ( + { get }, + id: string, + { + save, + functionIds, + + headers, + proxy, + + ...config + }: Partial + ) => { + const { conversations, callableFunctions } = get(); + const conversation = conversations.find( + (conversation) => conversation.id === id + ); + + if (!conversation) { + throw new Error(`Conversation with id ${id} not found`); + } + + conversation.setConfig(config, true); + conversation.setRequestOptions({ + headers, + proxy, + }); + + if (functionIds) { + conversation.clearFunctions(); + for (const functionId of functionIds) { + const callableFunction = callableFunctions.find( + (callableFunction) => callableFunction.id === functionId + ); + if (callableFunction) { + conversation.addFunction(callableFunction); + } + } + } + + // semi-HACK: This will ensure a re-render and persist. Both of these have no effect if they're already in the state we're setting them to. + if (save) { + addPersistedConversationId(id); + } else { + removePersistedConversationId(id); + } + + return conversation; + }, + "editConversation" +); diff --git a/packages/web/src/store/actions/conversations/importConversations.ts b/packages/web/src/store/actions/conversations/importConversations.ts new file mode 100644 index 0000000..d6dbff8 --- /dev/null +++ b/packages/web/src/store/actions/conversations/importConversations.ts @@ -0,0 +1,30 @@ +import { Conversation } from "gpt-turbo"; +import { ConversationExport } from "../../../entities/conversationExport"; +import { createAction } from "../createAction"; +import { addConversation } from "./addConversation"; +import { setConversationName } from "./setConversationName"; +import { addPersistedConversationId } from "../persistence/addPersistedConversationId"; + +export const importConversations = createAction( + async ({ get }, conversationExports: ConversationExport[]) => { + const { defaultSettings: settings } = get(); + const imported: Conversation[] = []; + + for (const { conversation: json, name } of conversationExports) { + const conversation = await Conversation.fromJSON({ + ...json, + config: { + ...json.config, + apiKey: settings.apiKey || undefined, + }, + }); + addConversation(conversation); + setConversationName(conversation.id, name); + addPersistedConversationId(conversation.id); + imported.push(conversation); + } + + return imported; + }, + "importConversations" +); diff --git a/packages/web/src/store/actions/persistence/addPersistedConversationId.ts b/packages/web/src/store/actions/persistence/addPersistedConversationId.ts index 21b346f..a8da5ba 100644 --- a/packages/web/src/store/actions/persistence/addPersistedConversationId.ts +++ b/packages/web/src/store/actions/persistence/addPersistedConversationId.ts @@ -3,7 +3,9 @@ import { createAction } from "../createAction"; export const addPersistedConversationId = createAction( ({ set }, id: string) => { set((state) => { - state.persistedConversationIds.push(id); + state.persistedConversationIds = state.persistedConversationIds + .filter((persistedId) => persistedId !== id) + .concat(id); }); }, "addPersistedConversationId" diff --git a/packages/web/src/store/actions/persistence/removePersistedConversationId.ts b/packages/web/src/store/actions/persistence/removePersistedConversationId.ts new file mode 100644 index 0000000..2e80bef --- /dev/null +++ b/packages/web/src/store/actions/persistence/removePersistedConversationId.ts @@ -0,0 +1,13 @@ +import { createAction } from "../createAction"; + +export const removePersistedConversationId = createAction( + ({ set }, id: string) => { + set((state) => { + state.persistedConversationIds = + state.persistedConversationIds.filter( + (persistedId) => persistedId !== id + ); + }); + }, + "removePersistedConversationId" +); diff --git a/packages/web/src/store/hooks/conversations/useGenerateConversationName.ts b/packages/web/src/store/hooks/conversations/useGenerateConversationName.ts new file mode 100644 index 0000000..6685c1a --- /dev/null +++ b/packages/web/src/store/hooks/conversations/useGenerateConversationName.ts @@ -0,0 +1,92 @@ +import React from "react"; +import { useAppStore } from "../.."; +import { shallow } from "zustand/shallow"; +import { + CallableFunction, + CallableFunctionObject, + CallableFunctionString, + ChatCompletionRequestMessageRoleEnum, + CompletionMessage, + Conversation, + Message, +} from "gpt-turbo"; + +const generateFn = new CallableFunction( + "setConversationName", + "Sets the name of the conversation based on the current messages. Ideally, under 32 characters.", + new CallableFunctionObject("_").addProperty( + new CallableFunctionString("name", { + description: "A short name to set the conversation name to.", + }) + ) +); + +const getFirstMessageOfRole = ( + messages: Message[], + role: R +) => { + const message = messages.find( + (m): m is CompletionMessage & { role: R } => + m.isCompletion() && m.role === role + ); + if (!message) return null; + return message; +}; + +export const useGenerateConversationName = (conversationId: string) => { + const [conversations, settings] = useAppStore( + (state) => [state.conversations, state.defaultSettings], + shallow + ); + const [isGenerating, setIsGenerating] = React.useState(false); + + const conversation = conversations.find((c) => c.id === conversationId); + + const apiKey = settings.apiKey; + const messages = conversation?.getMessages() ?? []; + const userMessage = getFirstMessageOfRole(messages, "user"); + const assistantMessage = getFirstMessageOfRole(messages, "assistant"); + + const canGenerate = React.useMemo( + () => conversation && apiKey && userMessage && assistantMessage, + [apiKey, assistantMessage, conversation, userMessage] + ); + + const generateConversationName = React.useCallback(async () => { + try { + if (!canGenerate) return; + + setIsGenerating(true); + const generateConversation = new Conversation({ + apiKey, + disableModeration: true, + }); + + await generateConversation.addUserMessage(userMessage!.content); + await generateConversation.addAssistantMessage( + assistantMessage!.content + ); + generateConversation.addFunction(generateFn); + + const result = await generateConversation.prompt( + "Give a name for this conversation based on the two previous messages.", + { function_call: { name: generateFn.name } } + ); + if (!result.isFunctionCall()) return; + + const { name } = result.functionCall.arguments; + if (!name) return; + setIsGenerating(false); + + return name as string; + } catch (e) { + console.error(e); + } + }, [apiKey, assistantMessage, canGenerate, userMessage]); + + return { + canGenerate, + isGenerating, + generateConversationName, + }; +}; diff --git a/packages/web/src/store/persist/migrations/1689537326770_conversation-export.ts b/packages/web/src/store/persist/migrations/1689537326770_conversation-export.ts new file mode 100644 index 0000000..e5cd1bd --- /dev/null +++ b/packages/web/src/store/persist/migrations/1689537326770_conversation-export.ts @@ -0,0 +1,10 @@ +import { produce } from "immer"; +import { StoreMigration } from "."; + +export const migrationConversationExport: StoreMigration = produce( + (persistedState) => { + for (const conversation of persistedState.conversations) { + conversation.name = conversation.name || "New Chat"; + } + } +); diff --git a/packages/web/src/store/persist/migrations/index.ts b/packages/web/src/store/persist/migrations/index.ts index 5f9b04e..d0a16fa 100644 --- a/packages/web/src/store/persist/migrations/index.ts +++ b/packages/web/src/store/persist/migrations/index.ts @@ -1,10 +1,14 @@ import { AppPersistedState } from "../.."; import { parsePersistedState } from "../parsePersistedState"; import { migrationChangelog } from "./1689216775706_changelog"; +import { migrationConversationExport } from "./1689537326770_conversation-export"; export type StoreMigration = (persistedState: any) => any | Promise; -export const storeMigrations: StoreMigration[] = [migrationChangelog]; +export const storeMigrations: StoreMigration[] = [ + migrationChangelog, + migrationConversationExport, +]; export const storeVersion = storeMigrations.length; diff --git a/packages/web/src/store/persist/parsePersistedState.ts b/packages/web/src/store/persist/parsePersistedState.ts index b42d2d4..6cc8c8c 100644 --- a/packages/web/src/store/persist/parsePersistedState.ts +++ b/packages/web/src/store/persist/parsePersistedState.ts @@ -22,6 +22,7 @@ export const parsePersistedState = async (persistedState: any) => { state.showUsage = appSettings.showUsage; state.colorScheme = appSettings.colorScheme; state.lastChangelog = appSettings.lastChangelog; + state.showConversationImport = appSettings.showConversationImport; // Callable Functions const { callableFunctions } = persistence; diff --git a/packages/web/src/store/persist/partializeStore.ts b/packages/web/src/store/persist/partializeStore.ts index e3b3f7c..84be476 100644 --- a/packages/web/src/store/persist/partializeStore.ts +++ b/packages/web/src/store/persist/partializeStore.ts @@ -8,6 +8,7 @@ export const partializeStore = (state: AppState): AppPersistedState => { showUsage: state.showUsage, colorScheme: state.colorScheme, lastChangelog: state.lastChangelog, + showConversationImport: state.showConversationImport, }); // Callable Functions diff --git a/packages/web/src/store/slices/appSettingsSlice.ts b/packages/web/src/store/slices/appSettingsSlice.ts index e92332a..9d175b2 100644 --- a/packages/web/src/store/slices/appSettingsSlice.ts +++ b/packages/web/src/store/slices/appSettingsSlice.ts @@ -5,12 +5,14 @@ export interface AppSettingsState { showUsage: boolean; colorScheme: ColorScheme; lastChangelog: string; + showConversationImport: boolean; } export const initialAppSettingsState: AppSettingsState = { showUsage: false, colorScheme: "light", lastChangelog: "", + showConversationImport: true, }; export const createAppSettingsSlice: AppStateSlice = () => diff --git a/packages/web/src/utils/readJsonFile.ts b/packages/web/src/utils/readJsonFile.ts new file mode 100644 index 0000000..0b4d03b --- /dev/null +++ b/packages/web/src/utils/readJsonFile.ts @@ -0,0 +1,17 @@ +import { ZodType } from "zod"; + +export default async (file: File, schema?: ZodType): Promise => { + const content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.onerror = (e) => { + reject(e); + }; + reader.readAsText(file); + }); + + const json = JSON.parse(content); + return schema ? schema.parse(json) : json; +};