diff --git a/package-lock.json b/package-lock.json index 03abf97c..3fc62a89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14068,6 +14068,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" @@ -14367,6 +14375,33 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zustand": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.1.tgz", + "integrity": "sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -14428,7 +14463,8 @@ "unified": "^11.0.4", "unist-util-visit": "^5.0.0", "uuid": "^9.0.1", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.5.1" }, "devDependencies": { "@tauri-apps/cli": "^1.5.9", diff --git a/webapp/components/assistants/Assistant.tsx b/webapp/components/assistants/Assistant.tsx new file mode 100644 index 00000000..aa107ba7 --- /dev/null +++ b/webapp/components/assistants/Assistant.tsx @@ -0,0 +1,115 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Bug, Settings2 } from 'lucide-react'; +import useTranslation from '@/hooks/useTranslation'; +import logger from '@/utils/logger'; +import { useAssistantStore } from '@/stores'; +import RecordView from '../common/RecordView'; +import { Button } from '../ui/button'; +import { ScrollArea } from '../ui/scroll-area'; +import Parameter, { ParameterValue } from '../common/Parameter'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; + +export type AssistantProps = { + assistantId?: string; +}; + +export default function AssistantView({ assistantId }: AssistantProps) { + const { t } = useTranslation(); + const { getAssistant, updateAssistant } = useAssistantStore(); + + const assistant = getAssistant(assistantId); + + logger.info('Assistant', assistantId); + + const handleUpdateParameter = (name: string, value: ParameterValue) => { + if (assistant) { + updateAssistant({ ...assistant, [name]: value }); + } + }; + + return ( + + +
{assistant.name}
+ + + + {t('Settings')} + + + + {t('Logs')} + + + + ) : ( + 'Assistant' + ) + } + selectedId={assistantId} + toolbar={ + assistantId && ( +
+ +
+ ) + } + > + {assistant && ( + <> + + +
+ + +
+
+
+ +
TODO
+
+ + )} +
+
+ ); +} diff --git a/webapp/components/assistants/Explorer.tsx b/webapp/components/assistants/Explorer.tsx new file mode 100644 index 00000000..590d77a8 --- /dev/null +++ b/webapp/components/assistants/Explorer.tsx @@ -0,0 +1,127 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useContext } from 'react'; +import { useRouter } from 'next/router'; +import { Plus } from 'lucide-react'; +import logger from '@/utils/logger'; +import { useAssistantStore } from '@/stores'; +import { Assistant, Ui } from '@/types'; +import useTranslation from '@/hooks/useTranslation'; +import { ModalIds } from '@/modals'; +import { ModalData, ModalsContext } from '@/context/modals'; +import { Page } from '@/types/ui'; +import Explorer, { ExplorerList } from '../common/Explorer'; +import { Button } from '../ui/button'; + +export default function AssistantsExplorer({ + selectedAssistantId, +}: { + selectedAssistantId?: string; +}) { + const router = useRouter(); + const { t } = useTranslation(); + const { showModal } = useContext(ModalsContext); + logger.info('AssistantsExplorer', selectedAssistantId); + const { assistants, getAssistant, createAssistant, updateAssistant, deleteAssistant } = + useAssistantStore(); + + const handleSelectItem = (id: string) => { + logger.info(`onSelectItem ${id}`); + const route = Ui.Page.Assistants; + router.push(`${route}/${id}`); + }; + + const handleToggle = (id: string) => { + const assistant = getAssistant(id); + if (assistant) { + assistant.disabled = !assistant.disabled; + updateAssistant(assistant); + } + logger.info(`onToggle ${id}`); + }; + + const handleDelete = async (action: string, data: ModalData) => { + const { id } = data.item; + if (action === 'Delete') { + logger.info(`onDelete ${id}`); + deleteAssistant(id); + if (selectedAssistantId && selectedAssistantId === id) { + router.replace(Page.Assistants); + } + } + }; + + const handleToDelete = (id: string) => { + logger.info(`onToDelete${id}`); + const assistant = getAssistant(id); + if (assistant) { + showModal(ModalIds.DeleteItem, { item: assistant, onAction: handleDelete }); + } + }; + + const menu: Ui.MenuItem[] = [ + { + label: t('Disable'), + onSelect: (data: string) => { + handleToggle(data); + }, + }, + { + label: t('Delete'), + onSelect: handleToDelete, + }, + ]; + const menuDisabled: Ui.MenuItem[] = [ + { + label: t('Enable'), + onSelect: (data: string) => { + logger.info(`enable ${data}`); + handleToggle(data); + }, + }, + { + label: t('Delete'), + onSelect: handleToDelete, + }, + ]; + + return ( + { + e.preventDefault(); + const assistant = createAssistant(`Assistant ${assistants.length + 1}`); + handleSelectItem(assistant.id); + }} + > + + + } + > + + selectedId={selectedAssistantId} + items={assistants} + onSelectItem={handleSelectItem} + menu={(assistant) => (assistant.disabled ? menuDisabled : menu)} + /> + + ); +} diff --git a/webapp/components/assistants/index.tsx b/webapp/components/assistants/index.tsx new file mode 100644 index 00000000..8631ecf6 --- /dev/null +++ b/webapp/components/assistants/index.tsx @@ -0,0 +1,35 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable'; +import Explorer from './Explorer'; +import Assistant from './Assistant'; + +export type AssistantProps = { + selectedAssistantId?: string; +}; + +export default function Assistants({ selectedAssistantId }: AssistantProps) { + return ( + + + + + + + + + + ); +} diff --git a/webapp/components/common/Explorer/ExplorerList.tsx b/webapp/components/common/Explorer/ExplorerList.tsx new file mode 100644 index 00000000..a460651d --- /dev/null +++ b/webapp/components/common/Explorer/ExplorerList.tsx @@ -0,0 +1,79 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'; +import ContextMenuList from '@/components/ui/ContextMenu/ContextMenuList'; +import { BaseNamedRecord, Ui } from '@/types'; + +export type ExplorerListProps = { + selectedId?: string; + items: T[]; + renderItem?: (item: T) => React.ReactNode; + menu?: (item: T) => Ui.MenuItem[]; + onSelectItem?: (id: string) => void; +}; + +export default function ExplorerList({ + selectedId, + items, + renderItem, + menu, + onSelectItem, +}: ExplorerListProps) { + const itemRendering = (item: BaseNamedRecord) => ( +
{}} + onClick={(e) => { + e.preventDefault(); + onSelectItem?.(item.id); + }} + className="w-full" + tabIndex={0} + > + {renderItem?.(item as T) ?? item.name} +
+ ); + + return ( +
+
+
+
    + {(items as BaseNamedRecord[]).map((item: BaseNamedRecord) => ( +
  • + {menu ? ( + + {itemRendering(item)} + + + ) : ( + itemRendering(item) + )} +
  • + ))} +
+
+
+
+ ); +} diff --git a/webapp/components/common/Explorer/index.tsx b/webapp/components/common/Explorer/index.tsx new file mode 100644 index 00000000..c8deecf0 --- /dev/null +++ b/webapp/components/common/Explorer/index.tsx @@ -0,0 +1,46 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import useTranslation from '@/hooks/useTranslation'; +import ExplorerList from './ExplorerList'; + +export type ExplorerProps = { + icon?: React.ReactNode; + title?: string; + children?: React.ReactNode; + toolbar?: React.ReactNode; +}; + +export default function Explorer({ icon, title = '', children, toolbar }: ExplorerProps) { + const { t } = useTranslation(); + + return ( +
+ +
+ ); +} + +export { ExplorerList }; diff --git a/webapp/components/common/RecordView/Header.tsx b/webapp/components/common/RecordView/Header.tsx new file mode 100644 index 00000000..ae1a5093 --- /dev/null +++ b/webapp/components/common/RecordView/Header.tsx @@ -0,0 +1,47 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Copyright 2023 Mik Bry +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export type ToolbarProps = { + title: string | React.ReactNode; + toolbar?: React.ReactNode; +}; + +export default function Header({ title, toolbar }: ToolbarProps) { + return ( +
+
+
+ {typeof title === 'string' && ( + {title} + )} + {typeof title !== 'string' && title} +
+
{toolbar}
+
+
+ ); +} diff --git a/webapp/components/common/RecordView/index.tsx b/webapp/components/common/RecordView/index.tsx new file mode 100644 index 00000000..a91212f4 --- /dev/null +++ b/webapp/components/common/RecordView/index.tsx @@ -0,0 +1,37 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// import useTranslation from '@/hooks/useTranslation'; +import Header from './Header'; + +export type RecordViewProps = { + title: string | React.ReactNode; + selectedId?: string; + children: React.ReactNode; + toolbar?: React.ReactNode; +}; + +export default function RecordView({ title, selectedId, children, toolbar }: RecordViewProps) { + // const { t } = useTranslation(); + return ( +
+
+
+
+ {children} +
+
+
+ ); +} diff --git a/webapp/components/common/Sidebar/index.tsx b/webapp/components/common/Sidebar/index.tsx index 2946c115..c4bec6b7 100644 --- a/webapp/components/common/Sidebar/index.tsx +++ b/webapp/components/common/Sidebar/index.tsx @@ -17,6 +17,7 @@ import { useContext } from 'react'; import Image from 'next/image'; import { + Bot, BrainCircuit, Keyboard, LucideIcon, @@ -52,6 +53,13 @@ const sidebarItems: Array = [ icon: MessagesSquare, shortcut: ShortcutIds.DISPLAY_THREADS, }, + { + name: 'Assistants', + href: Ui.Page.Assistants, + page: Ui.Page.Assistants, + icon: Bot, + shortcut: ShortcutIds.DISPLAY_ASSISTANTS, + }, { name: 'Models', href: Ui.Page.Models, diff --git a/webapp/components/providers/Provider.tsx b/webapp/components/providers/Provider.tsx index 9c49f910..78e9a7c3 100644 --- a/webapp/components/providers/Provider.tsx +++ b/webapp/components/providers/Provider.tsx @@ -27,7 +27,7 @@ import OplaActions from './opla/Actions'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { ScrollArea } from '../ui/scroll-area'; -function ProviderConfiguration({ providerId }: { providerId?: string }) { +function ProviderView({ providerId }: { providerId?: string }) { const { t } = useTranslation(); const { provider, hasParametersChanged, onParametersSave, onParameterChange, onProviderToggle } = @@ -145,4 +145,4 @@ function ProviderConfiguration({ providerId }: { providerId?: string }) { ); } -export default ProviderConfiguration; +export default ProviderView; diff --git a/webapp/components/threads/Presets.tsx b/webapp/components/threads/Presets.tsx index 8d8e29df..b14df79a 100644 --- a/webapp/components/threads/Presets.tsx +++ b/webapp/components/threads/Presets.tsx @@ -57,7 +57,7 @@ export default function Presets({ const compatibles = getCompatiblePresets(presets, model, provider); const duplicatePreset = (p: Preset, newName?: string) => { - const { id, name, readOnly, ...rest } = p; + const { id, name, readonly, ...rest } = p; const template = deepMerge(rest, presetProperties); const newPreset = createPreset(newName || `${p.id}-copy`, p.parentId || id, template); setPresets([...presets, newPreset]); @@ -136,7 +136,7 @@ export default function Presets({ {t('Duplicate selected preset')} - {!preset?.readOnly && ( + {!preset?.readonly && ( { diff --git a/webapp/hooks/useAssistantStoreContext.tsx b/webapp/hooks/useAssistantStoreContext.tsx new file mode 100644 index 00000000..8f5d4ba3 --- /dev/null +++ b/webapp/hooks/useAssistantStoreContext.tsx @@ -0,0 +1,13 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. diff --git a/webapp/hooks/useShortcuts.tsx b/webapp/hooks/useShortcuts.tsx index af1aa2ac..376a2a50 100644 --- a/webapp/hooks/useShortcuts.tsx +++ b/webapp/hooks/useShortcuts.tsx @@ -26,6 +26,7 @@ export type KeyBinding = { export enum ShortcutIds { DISPLAY_THREADS = '#display-threads', + DISPLAY_ASSISTANTS = '#display-assistants', DISPLAY_MODELS = '#display-models', DISPLAY_PROVIDERS = '#display-providers', DISPLAY_SETTINGS = '#display-settings', @@ -55,10 +56,15 @@ export enum ShortcutIds { // mod is the command key on mac, ctrl on windows/linux export const defaultShortcuts: KeyBinding[] = [ { command: ShortcutIds.DISPLAY_THREADS, keys: ['mod+1'], description: 'Display threads panel' }, - { command: ShortcutIds.DISPLAY_MODELS, keys: ['mod+2'], description: 'Display models panel' }, + { + command: ShortcutIds.DISPLAY_ASSISTANTS, + keys: ['mod+2'], + description: 'Display assistants panel', + }, + { command: ShortcutIds.DISPLAY_MODELS, keys: ['mod+3'], description: 'Display models panel' }, { command: ShortcutIds.DISPLAY_PROVIDERS, - keys: ['mod+3'], + keys: ['mod+4'], description: 'Display providers panel', }, { command: ShortcutIds.DISPLAY_SETTINGS, keys: ['mod+t'], description: 'Toggle Settings' }, diff --git a/webapp/package.json b/webapp/package.json index 23a6280b..251ca9bc 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -60,7 +60,8 @@ "unified": "^11.0.4", "unist-util-visit": "^5.0.0", "uuid": "^9.0.1", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.5.1" }, "devDependencies": { "@tauri-apps/cli": "^1.5.9", diff --git a/webapp/pages/assistants/[id].tsx b/webapp/pages/assistants/[id].tsx new file mode 100644 index 00000000..610a64d4 --- /dev/null +++ b/webapp/pages/assistants/[id].tsx @@ -0,0 +1,24 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use client'; + +import Assistants from '@/components/assistants'; +import { useRouter } from 'next/router'; + +export default function DefaultAssistants() { + const router = useRouter(); + const { id } = router.query; + return ; +} diff --git a/webapp/pages/assistants/index.tsx b/webapp/pages/assistants/index.tsx new file mode 100644 index 00000000..c4b02713 --- /dev/null +++ b/webapp/pages/assistants/index.tsx @@ -0,0 +1,21 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use client'; + +import Assistants from '@/components/assistants'; + +export default function DefaultAssistants() { + return ; +} diff --git a/webapp/stores/assistants.ts b/webapp/stores/assistants.ts new file mode 100644 index 00000000..78fbf74b --- /dev/null +++ b/webapp/stores/assistants.ts @@ -0,0 +1,60 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { StateCreator } from 'zustand'; +import { Assistant } from '@/types'; +import { createBaseNamedRecord, updateRecord } from '@/utils/data'; + +interface AssistantProps { + assistants: Assistant[]; +} + +export interface AssistantSlice extends AssistantProps { + getAssistant: (id: string | undefined) => Assistant | undefined; + createAssistant: (name: string, template?: Partial) => Assistant; + updateAssistant: (newAssistant: Assistant) => void; + deleteAssistant: (id: string) => void; +} + +export type AssistantStore = ReturnType; + +const DEFAULT_PROPS: AssistantProps = { + assistants: [], +}; + +const createAssistantSlice = + (initProps?: Partial): StateCreator => + (set, get) => ({ + ...DEFAULT_PROPS, + ...initProps, + getAssistant: (id: string | undefined) => get().assistants.find((a) => a.id === id), + createAssistant: (name: string, template?: Partial) => { + const newAssistant = createBaseNamedRecord(name, template); + set((state: AssistantSlice) => ({ assistants: [...state.assistants, newAssistant] })); + return newAssistant; + }, + updateAssistant: (newAssistant: Assistant) => { + const updatedAssistant: Assistant = updateRecord(newAssistant); + set((state: AssistantSlice) => ({ + assistants: state.assistants.map((a) => + a.id === updatedAssistant.id ? updatedAssistant : a, + ), + })); + }, + deleteAssistant: (id: string) => { + set((state: AssistantSlice) => ({ assistants: state.assistants.filter((a) => a.id !== id) })); + }, + }); + +export default createAssistantSlice; diff --git a/webapp/stores/index.ts b/webapp/stores/index.ts new file mode 100644 index 00000000..7ff5a23d --- /dev/null +++ b/webapp/stores/index.ts @@ -0,0 +1,33 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { Assistant } from '@/types'; +import createAssistantSlice, { AssistantSlice } from './assistants'; +import Storage from './storage'; + +export const useAssistantStore = create()( + persist( + (...a) => ({ + ...createAssistantSlice()(...a), + }), + { + name: 'assistant', + storage: createJSONStorage(() => Storage, {}), + }, + ), +); + +export default useAssistantStore; diff --git a/webapp/stores/storage.ts b/webapp/stores/storage.ts new file mode 100644 index 00000000..6f490150 --- /dev/null +++ b/webapp/stores/storage.ts @@ -0,0 +1,46 @@ +// Copyright 2024 mik +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { StateStorage } from 'zustand/middleware'; +import dataStorage, { StorageType } from '@/utils/dataStorage'; + +async function get(name: string): Promise { + const value = (await dataStorage(StorageType.TextFile).getItem(name)) as string; + return value; +} + +async function set(name: string, value: string): Promise { + await dataStorage(StorageType.TextFile).setItem(name, value); +} + +async function del(name: string): Promise { + await dataStorage(StorageType.TextFile).setItem(name, undefined); +} + +// Custom storage object +const storage: StateStorage = { + getItem: async (name: string): Promise => + /* logger.info(name, 'has been retrieved'); */ + (await get(name)) || null, + setItem: async (name: string, value: string): Promise => { + // logger.info(name, 'with value', value, 'has been saved'); + await set(name, value); + }, + removeItem: async (name: string): Promise => { + // logger.info(name, 'has been deleted'); + await del(name); + }, +}; + +export default storage; diff --git a/webapp/types/index.ts b/webapp/types/index.ts index 9115f14b..09ba8604 100644 --- a/webapp/types/index.ts +++ b/webapp/types/index.ts @@ -110,7 +110,7 @@ export type Preset = BaseNamedRecord & { provider?: string; models?: string[]; - readOnly?: boolean; + readonly?: boolean; system?: string; parameters?: Record; @@ -346,6 +346,25 @@ export type ModelsConfiguration = { items: Array; }; +export type AssistantTarget = { + id: string; + parent?: string; + enabled?: boolean; + models?: string[]; + provider?: string; + + system?: string; + parameters?: Record; + contextWindowPolicy?: ContextWindowPolicy; + keepSystem?: boolean; +}; +export type Assistant = BaseNamedRecord & { + disabled?: boolean; + parent?: string; + readonly?: boolean; + targets?: AssistantTarget[]; +}; + export type Store = { settings: Settings; server: ServerConfiguration; diff --git a/webapp/types/ui.ts b/webapp/types/ui.ts index 1f1224e6..cda6122b 100644 --- a/webapp/types/ui.ts +++ b/webapp/types/ui.ts @@ -63,6 +63,7 @@ export enum ViewName { export enum Page { Threads = '/threads', Archives = '/archives', + Assistants = '/assistants', Models = '/models', Providers = '/providers', } diff --git a/webapp/utils/data/index.ts b/webapp/utils/data/index.ts index 8d6665d4..6dada8bc 100644 --- a/webapp/utils/data/index.ts +++ b/webapp/utils/data/index.ts @@ -23,19 +23,18 @@ const createBaseRecord = () => { return item as T; }; -const updateRecord = (item: BaseIdRecord) => ({ - ...item, - updatedAt: Date.now(), -}); +const updateRecord = (item: BaseIdRecord) => + ({ + ...item, + updatedAt: Date.now(), + }) as T; -const createBaseNamedRecord = (name: string, description?: string): T => { +const createBaseNamedRecord = (name: string, template?: Partial): T => { const item: BaseNamedRecord = { + ...template, ...createBaseRecord(), name, }; - if (description) { - item.description = description; - } return item as T; }; diff --git a/webapp/utils/data/presets.ts b/webapp/utils/data/presets.ts index 4f5c6bb5..2c4306e7 100644 --- a/webapp/utils/data/presets.ts +++ b/webapp/utils/data/presets.ts @@ -19,14 +19,14 @@ export const defaultPresets: Preset[] = [ { id: 'opla', name: 'Opla', - readOnly: true, + readonly: true, updatedAt: 0, createdAt: 0, }, { id: 'openai', name: 'OpenAI', - readOnly: true, + readonly: true, updatedAt: 0, createdAt: 0, }, @@ -34,7 +34,7 @@ export const defaultPresets: Preset[] = [ id: 'gpt-3.5', parentId: 'openai', name: 'ChatGPT-3.5', - readOnly: true, + readonly: true, updatedAt: 0, createdAt: 0, }, @@ -42,7 +42,7 @@ export const defaultPresets: Preset[] = [ id: 'gpt-4', parentId: 'openai', name: 'ChatGPT-4', - readOnly: true, + readonly: true, updatedAt: 0, createdAt: 0, }, @@ -113,7 +113,7 @@ export const getCompatiblePresets = (presets: Preset[], model?: string, provider } }); presets.forEach((p) => { - if (p.parentId && compatiblePresets[p.parentId] && !p.readOnly) { + if (p.parentId && compatiblePresets[p.parentId] && !p.readonly) { compatiblePresets[p.id] = true; } }); diff --git a/webapp/utils/dataStorage.ts b/webapp/utils/dataStorage.ts index 5adc649f..33363539 100644 --- a/webapp/utils/dataStorage.ts +++ b/webapp/utils/dataStorage.ts @@ -16,8 +16,9 @@ import { toast } from 'sonner'; import logger from './logger'; import { deleteDir, deleteFile, readTextFile, writeTextFile } from './backend/tauri'; -enum StorageType { - File, +export enum StorageType { + JSON, + TextFile, LocalStorage, } @@ -28,7 +29,7 @@ type DataStorage = { const createPathAndJsonFile = (key: string, path: string) => `${path}/${key}.json`; -const readFromLocalStorage = async (key: string, path: string) => { +const readFromLocalStorage = async (key: string, path: string, json = true) => { let text: string; try { text = await readTextFile(createPathAndJsonFile(key, path)); @@ -37,6 +38,9 @@ const readFromLocalStorage = async (key: string, path: string) => { return null; } + if (!json) { + return text as T; + } try { return JSON.parse(text) as T; } catch (e) { @@ -46,8 +50,12 @@ const readFromLocalStorage = async (key: string, path: string) => { return null; }; -const writeToLocalStorage = async (key: string, value: T, path: string) => { - await writeTextFile(createPathAndJsonFile(key, path), JSON.stringify(value, null, 2), true); +const writeToLocalStorage = async (key: string, value: T, path: string, json = true) => { + await writeTextFile( + createPathAndJsonFile(key, path), + json ? JSON.stringify(value, null, 2) : (value as string), + true, + ); }; const deleteFromLocalStorage = async (key: string, path: string) => { @@ -95,16 +103,10 @@ const MockStorage: DataStorage = { }, }; -const FileStorage: DataStorage = { +const JsonStorage: DataStorage = { async getItem(key: string, defaultValue?: T, path = '') { - logger.warn('FileStorage.getItem() called', path); + // logger.warn('JsonStorage.getItem() called', path); const value = await readFromLocalStorage(key, path); - /* if (!value || (Array.isArray(value) && value.length === 0)) { - value = await LocalStorage.getItem(key, defaultValue); - if (value || defaultValue) { - await writeToLocalStorage(key, value || defaultValue, path); - } - } */ return value || (defaultValue as T); }, async setItem(key: string, value: T, path = '') { @@ -115,11 +117,28 @@ const FileStorage: DataStorage = { }, }; -const persistentStorage = (type = StorageType.File): DataStorage => { +const TextFileStorage: DataStorage = { + async getItem(key: string, defaultValue?: T, path = '') { + // logger.warn('TextFileStorage.getItem() called', path); + const value = await readFromLocalStorage(key, path, false); + return value || (defaultValue as T); + }, + async setItem(key: string, value: T, path = '') { + if (value === undefined) { + return deleteFromLocalStorage(key, path); + } + // logger.info('TextFileStorage.setItem()', key, value, path); + return writeToLocalStorage(key, value, path, false); + }, +}; + +const persistentStorage = (type = StorageType.JSON): DataStorage => { if (window && window.localStorage) { switch (type) { - case StorageType.File: - return FileStorage; + case StorageType.JSON: + return JsonStorage; + case StorageType.TextFile: + return TextFileStorage; case StorageType.LocalStorage: return LocalStorage; default: