diff --git a/src/components/Settings/Sections/General.tsx b/src/components/Settings/Sections/General.tsx new file mode 100644 index 0000000..18b77bc --- /dev/null +++ b/src/components/Settings/Sections/General.tsx @@ -0,0 +1,29 @@ +import { UIConfigField } from '@/lib/config/types'; +import SettingsField from '../SettingsField'; + +const General = ({ + fields, + values, +}: { + fields: UIConfigField[]; + values: Record; +}) => { + return ( +
+ {fields.map((field) => ( + + ))} +
+ ); +}; + +export default General; diff --git a/src/components/Settings/Sections/Models/AddModelDialog.tsx b/src/components/Settings/Sections/Models/AddModelDialog.tsx new file mode 100644 index 0000000..7df98de --- /dev/null +++ b/src/components/Settings/Sections/Models/AddModelDialog.tsx @@ -0,0 +1,163 @@ +import { Dialog, DialogPanel } from '@headlessui/react'; +import { Loader2, Plus } from 'lucide-react'; +import { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ConfigModelProvider } from '@/lib/config/types'; +import { toast } from 'sonner'; + +const AddModel = ({ + providerId, + setProviders, + type, +}: { + providerId: string; + setProviders: React.Dispatch>; + type: 'chat' | 'embedding'; +}) => { + const [open, setOpen] = useState(false); + const [modelName, setModelName] = useState(''); + const [modelKey, setModelKey] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch(`/api/providers/${providerId}/models`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: modelName, + key: modelKey, + type: type, + }), + }); + + if (!res.ok) { + throw new Error('Failed to add model'); + } + + setProviders((prev) => + prev.map((provider) => { + if (provider.id === providerId) { + const newModel = { name: modelName, key: modelKey }; + return { + ...provider, + chatModels: + type === 'chat' + ? [...provider.chatModels, newModel] + : provider.chatModels, + embeddingModels: + type === 'embedding' + ? [...provider.embeddingModels, newModel] + : provider.embeddingModels, + }; + } + return provider; + }), + ); + + toast.success('Model added successfully.'); + setModelName(''); + setModelKey(''); + setOpen(false); + } catch (error) { + console.error('Error adding model:', error); + toast.error('Failed to add model.'); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + {open && ( + setOpen(false)} + className="relative z-[60]" + > + + +
+

+ Add new {type === 'chat' ? 'chat' : 'embedding'} model +

+
+
+
+
+
+
+ + setModelName(e.target.value)} + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder="e.g., GPT-4" + type="text" + required + /> +
+
+ + setModelKey(e.target.value)} + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder="e.g., gpt-4" + type="text" + required + /> +
+
+
+
+ +
+ +
+ + +
+ )} +
+ + ); +}; + +export default AddModel; diff --git a/src/components/Settings/Sections/Models/AddProviderDialog.tsx b/src/components/Settings/Sections/Models/AddProviderDialog.tsx new file mode 100644 index 0000000..1dd033d --- /dev/null +++ b/src/components/Settings/Sections/Models/AddProviderDialog.tsx @@ -0,0 +1,216 @@ +import { + Description, + Dialog, + DialogPanel, + DialogTitle, +} from '@headlessui/react'; +import { Loader2, Plus } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + ConfigModelProvider, + ModelProviderUISection, + StringUIConfigField, + UIConfigField, +} from '@/lib/config/types'; +import Select from '@/components/ui/Select'; +import { toast } from 'sonner'; + +const AddProvider = ({ + modelProviders, + setProviders, +}: { + modelProviders: ModelProviderUISection[]; + setProviders: React.Dispatch>; +}) => { + const [open, setOpen] = useState(false); + const [selectedProvider, setSelectedProvider] = useState( + modelProviders[0]?.key || null, + ); + const [config, setConfig] = useState>({}); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + + const providerConfigMap = useMemo(() => { + const map: Record = {}; + + modelProviders.forEach((p) => { + map[p.key] = { + name: p.name, + fields: p.fields, + }; + }); + + return map; + }, [modelProviders]); + + const selectedProviderFields = useMemo(() => { + if (!selectedProvider) return []; + const providerFields = providerConfigMap[selectedProvider]?.fields || []; + const config: Record = {}; + + providerFields.forEach((field) => { + config[field.key] = field.default || ''; + }); + + setConfig(config); + + return providerFields; + }, [selectedProvider, providerConfigMap]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch('/api/providers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: selectedProvider, + name: name, + config: config, + }), + }); + + if (!res.ok) { + throw new Error('Failed to add provider'); + } + + const data: ConfigModelProvider = (await res.json()).provider; + + setProviders((prev) => [...prev, data]); + + toast.success('Provider added successfully.'); + } catch (error) { + console.error('Error adding provider:', error); + toast.error('Failed to add provider.'); + } finally { + setLoading(false); + setOpen(false); + } + }; + + return ( + <> + + + {open && ( + setOpen(false)} + className="relative z-[60]" + > + + +
+
+

+ Add new provider +

+
+
+
+
+
+ + setName(e.target.value)} + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder={'Provider Name'} + type="text" + required={true} + /> +
+ + {selectedProviderFields.map((field: UIConfigField) => ( +
+ + + setConfig((prev) => ({ + ...prev, + [field.key]: event.target.value, + })) + } + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder={ + (field as StringUIConfigField).placeholder + } + type="text" + required={field.required} + /> +
+ ))} +
+
+
+
+ +
+ + + +
+ )} +
+ + ); +}; + +export default AddProvider; diff --git a/src/components/Settings/Sections/Models/DeleteProviderDialog.tsx b/src/components/Settings/Sections/Models/DeleteProviderDialog.tsx new file mode 100644 index 0000000..a33d383 --- /dev/null +++ b/src/components/Settings/Sections/Models/DeleteProviderDialog.tsx @@ -0,0 +1,119 @@ +import { Dialog, DialogPanel } from '@headlessui/react'; +import { Loader2, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + ConfigModelProvider, +} from '@/lib/config/types'; +import { toast } from 'sonner'; + +const DeleteProvider = ({ + modelProvider, + setProviders, +}: { + modelProvider: ConfigModelProvider; + setProviders: React.Dispatch>; +}) => { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const handleDelete = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch(`/api/providers/${modelProvider.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + throw new Error('Failed to delete provider'); + } + + setProviders((prev) => { + return prev.filter((p) => p.id !== modelProvider.id); + }); + + toast.success('Provider deleted successfully.'); + } catch (error) { + console.error('Error deleting provider:', error); + toast.error('Failed to delete provider.'); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + {open && ( + setOpen(false)} + className="relative z-[60]" + > + + +
+

+ Delete provider +

+
+
+
+

+ Are you sure you want to delete the provider "{modelProvider.name}"? This action cannot be undone. +

+
+
+ + +
+ + +
+ )} +
+ + ); +}; + +export default DeleteProvider; diff --git a/src/components/Settings/Sections/Models/ModelProvider.tsx b/src/components/Settings/Sections/Models/ModelProvider.tsx new file mode 100644 index 0000000..fecb5f3 --- /dev/null +++ b/src/components/Settings/Sections/Models/ModelProvider.tsx @@ -0,0 +1,207 @@ +import { UIConfigField, ConfigModelProvider } from '@/lib/config/types'; +import { cn } from '@/lib/utils'; +import { AnimatePresence, motion } from 'framer-motion'; +import { AlertCircle, ChevronDown, Pencil, Trash2, X } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import AddModel from './AddModelDialog'; +import UpdateProvider from './UpdateProviderDialog'; +import DeleteProvider from './DeleteProviderDialog'; + +const ModelProvider = ({ + modelProvider, + setProviders, + fields, +}: { + modelProvider: ConfigModelProvider; + fields: UIConfigField[]; + setProviders: React.Dispatch>; +}) => { + const [open, setOpen] = useState(false); + + const handleModelDelete = async ( + type: 'chat' | 'embedding', + modelKey: string, + ) => { + try { + const res = await fetch(`/api/providers/${modelProvider.id}/models`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key: modelKey, type: type }), + }); + + if (!res.ok) { + throw new Error('Failed to delete model: ' + (await res.text())); + } + + setProviders( + (prev) => + prev.map((provider) => { + if (provider.id === modelProvider.id) { + return { + ...provider, + ...(type === 'chat' + ? { + chatModels: provider.chatModels.filter( + (m) => m.key !== modelKey, + ), + } + : { + embeddingModels: provider.embeddingModels.filter( + (m) => m.key !== modelKey, + ), + }), + }; + } + return provider; + }) as ConfigModelProvider[], + ); + + toast.success('Model deleted successfully.'); + } catch (err) { + console.error('Failed to delete model', err); + toast.error('Failed to delete model.'); + } + }; + + return ( +
+
setOpen(!open)} + > +

+ {modelProvider.name} +

+
+
+ + +
+ +
+
+ + {open && ( + +
+
+ {modelProvider.chatModels.length > 0 && ( +
+
+

+ Chat models +

+ +
+
+ {modelProvider.chatModels.some((m) => m.key === 'error') ? ( +
+ + + {modelProvider.chatModels.find((m) => m.key === 'error')?.name} + +
+ ) : ( +
+ {modelProvider.chatModels.map((model, index) => ( +
+ {model.name} + +
+ ))} +
+ )} +
+
+ )} + {modelProvider.embeddingModels.length > 0 && ( +
+
+

+ Embedding models +

+ +
+
+ {modelProvider.embeddingModels.some((m) => m.key === 'error') ? ( +
+ + + {modelProvider.embeddingModels.find((m) => m.key === 'error')?.name} + +
+ ) : ( +
+ {modelProvider.embeddingModels.map((model, index) => ( +
+ {model.name} + +
+ ))} +
+ )} +
+
+ )} +
+ + )} + +
+ ); +}; + +export default ModelProvider; diff --git a/src/components/Settings/Sections/Models/Section.tsx b/src/components/Settings/Sections/Models/Section.tsx new file mode 100644 index 0000000..67e9996 --- /dev/null +++ b/src/components/Settings/Sections/Models/Section.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import AddProvider from './AddProviderDialog'; +import { + ConfigModelProvider, + ModelProviderUISection, + UIConfigField, +} from '@/lib/config/types'; +import ModelProvider from './ModelProvider'; + +const Models = ({ + fields, + values, +}: { + fields: ModelProviderUISection[]; + values: ConfigModelProvider[]; +}) => { + const [providers, setProviders] = useState(values); + + return ( +
+
+

+ Manage model provider +

+ +
+
+ {providers.map((provider) => ( + f.key === provider.type)?.fields ?? + []) as UIConfigField[] + } + modelProvider={provider} + setProviders={setProviders} + /> + ))} +
+
+ ); +}; + +export default Models; diff --git a/src/components/Settings/Sections/Models/UpdateProviderDialog.tsx b/src/components/Settings/Sections/Models/UpdateProviderDialog.tsx new file mode 100644 index 0000000..17318b0 --- /dev/null +++ b/src/components/Settings/Sections/Models/UpdateProviderDialog.tsx @@ -0,0 +1,188 @@ +import { Dialog, DialogPanel } from '@headlessui/react'; +import { Loader2, Pencil } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + ConfigModelProvider, + StringUIConfigField, + UIConfigField, +} from '@/lib/config/types'; +import { toast } from 'sonner'; + +const UpdateProvider = ({ + modelProvider, + fields, + setProviders, +}: { + fields: UIConfigField[]; + modelProvider: ConfigModelProvider; + setProviders: React.Dispatch>; +}) => { + const [open, setOpen] = useState(false); + const [config, setConfig] = useState>({}); + const [name, setName] = useState(modelProvider.name); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const config: Record = { + name: modelProvider.name, + }; + + fields.forEach((field) => { + config[field.key] = + modelProvider.config[field.key] || field.default || ''; + }); + + setConfig(config); + }, [fields]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch(`/api/providers/${modelProvider.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name, + config: config, + }), + }); + + if (!res.ok) { + throw new Error('Failed to update provider'); + } + + const data: ConfigModelProvider = (await res.json()).provider; + + setProviders((prev) => { + return prev.map((p) => { + if (p.id === modelProvider.id) { + return data; + } + + return p; + }); + }); + + toast.success('Provider updated successfully.'); + } catch (error) { + console.error('Error updating provider:', error); + toast.error('Failed to update provider.'); + } finally { + setLoading(false); + setOpen(false); + } + }; + + return ( + <> + + + {open && ( + setOpen(false)} + className="relative z-[60]" + > + + +
+
+

+ Update provider +

+
+
+
+
+
+ + setName(event.target.value)} + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder={'Provider Name'} + type="text" + required={true} + /> +
+ + {fields.map((field: UIConfigField) => ( +
+ + + setConfig((prev) => ({ + ...prev, + [field.key]: event.target.value, + })) + } + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder={ + (field as StringUIConfigField).placeholder + } + type="text" + required={field.required} + /> +
+ ))} +
+
+
+
+ +
+ + + +
+ )} +
+ + ); +}; + +export default UpdateProvider; diff --git a/src/components/Settings/Sections/Search.tsx b/src/components/Settings/Sections/Search.tsx new file mode 100644 index 0000000..58b7ab6 --- /dev/null +++ b/src/components/Settings/Sections/Search.tsx @@ -0,0 +1,29 @@ +import { UIConfigField } from '@/lib/config/types'; +import SettingsField from '../SettingsField'; + +const Search = ({ + fields, + values, +}: { + fields: UIConfigField[]; + values: Record; +}) => { + return ( +
+ {fields.map((field) => ( + + ))} +
+ ); +}; + +export default Search; diff --git a/src/components/Settings/SettingsButton.tsx b/src/components/Settings/SettingsButton.tsx new file mode 100644 index 0000000..da8538c --- /dev/null +++ b/src/components/Settings/SettingsButton.tsx @@ -0,0 +1,24 @@ +import { Settings } from 'lucide-react'; +import { useState } from 'react'; +import SettingsDialogue from './SettingsDialogue'; +import { AnimatePresence } from 'framer-motion'; + +const SettingsButton = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> +
setIsOpen(true)} + > + +
+ + {isOpen && } + + + ); +}; + +export default SettingsButton; diff --git a/src/components/Settings/SettingsDialogue.tsx b/src/components/Settings/SettingsDialogue.tsx new file mode 100644 index 0000000..f9291b1 --- /dev/null +++ b/src/components/Settings/SettingsDialogue.tsx @@ -0,0 +1,152 @@ +import { Dialog, DialogPanel } from '@headlessui/react'; +import { BrainCog, ChevronLeft, Search, Settings } from 'lucide-react'; +import General from './Sections/General'; +import { motion } from 'framer-motion'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import Loader from '../ui/Loader'; +import { cn } from '@/lib/utils'; +import Models from './Sections/Models/Section'; +import SearchSection from './Sections/Search'; + +const sections = [ + { + name: 'General', + description: 'Adjust common settings.', + icon: Settings, + component: General, + dataAdd: 'general', + }, + { + name: 'Models', + description: 'Configure model settings.', + icon: BrainCog, + component: Models, + dataAdd: 'modelProviders', + }, + { + name: 'Search', + description: 'Manage search settings.', + icon: Search, + component: SearchSection, + dataAdd: 'search', + }, +]; + +const SettingsDialogue = ({ + isOpen, + setIsOpen, +}: { + isOpen: boolean; + setIsOpen: (active: boolean) => void; +}) => { + const [isLoading, setIsLoading] = useState(true); + const [config, setConfig] = useState(null); + const [activeSection, setActiveSection] = useState(sections[0]); + + useEffect(() => { + if (isOpen) { + const fetchConfig = async () => { + try { + const res = await fetch('/api/config', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await res.json(); + + setConfig(data); + } catch (error) { + console.error('Error fetching config:', error); + toast.error('Failed to load configuration.'); + } finally { + setIsLoading(false); + } + }; + + fetchConfig(); + } + }, [isOpen]); + + return ( + setIsOpen(false)} + className="relative z-50" + > + + + {isLoading ? ( +
+ +
+ ) : ( +
+
+ +
+ {sections.map((section) => ( + + ))} +
+
+
+ {activeSection.component && ( +
+
+
+

+ {activeSection.name} +

+

+ {activeSection.description} +

+
+
+ +
+ )} +
+
+ )} +
+
+
+ ); +}; + +export default SettingsDialogue; diff --git a/src/components/Settings/SettingsField.tsx b/src/components/Settings/SettingsField.tsx new file mode 100644 index 0000000..c1b0d9d --- /dev/null +++ b/src/components/Settings/SettingsField.tsx @@ -0,0 +1,194 @@ +import { + SelectUIConfigField, + StringUIConfigField, + UIConfigField, +} from '@/lib/config/types'; +import { useState } from 'react'; +import Select from '../ui/Select'; +import { toast } from 'sonner'; +import { useTheme } from 'next-themes'; +import { Loader2 } from 'lucide-react'; + +const SettingsSelect = ({ + field, + value, + setValue, + dataAdd, +}: { + field: SelectUIConfigField; + value?: any; + setValue: (value: any) => void; + dataAdd: string; +}) => { + const [loading, setLoading] = useState(false); + const { setTheme } = useTheme(); + + const handleSave = async (newValue: any) => { + setLoading(true); + setValue(newValue); + try { + if (field.scope === 'client') { + localStorage.setItem(field.key, newValue); + if (field.key === 'theme') { + setTheme(newValue); + } + } else { + const res = await fetch('/api/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + key: `${dataAdd}.${field.key}`, + value: newValue, + }), + }); + + if (!res.ok) { + console.error('Failed to save config:', await res.text()); + throw new Error('Failed to save configuration'); + } + } + } catch (error) { + console.error('Error saving config:', error); + toast.error('Failed to save configuration.'); + } finally { + setTimeout(() => setLoading(false), 150); + } + }; + + return ( +
+
+
+

{field.name}

+

+ {field.description} +

+
+ setValue(event.target.value)} + onBlur={(event) => handleSave(event.target.value)} + className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60" + placeholder={field.placeholder} + type="text" + disabled={loading} + /> + {loading && ( + + + + )} +
+
+ + ); +}; + +const SettingsField = ({ + field, + value, + dataAdd, +}: { + field: UIConfigField; + value: any; + dataAdd: string; +}) => { + const [val, setVal] = useState(value); + + switch (field.type) { + case 'select': + return ( + + ); + case 'string': + return ( + + ); + default: + return
Unsupported field type: {field.type}
; + } +}; + +export default SettingsField;