Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(settings): rewrite settings #7

Merged
merged 1 commit into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/components/input-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import { type SubmitHandler, useForm } from 'react-hook-form'
import type { charactersTable } from '../db/schema'

import { useMessages, useSetMessages } from '../context/messages'
import { useChatModel } from '../hooks/use-model'
import { useChatProvider } from '../hooks/use-model'

export interface Inputs {
content: string
}

export const InputArea = ({ character }: { character?: typeof charactersTable.$inferSelect }) => {
const [chatModel] = useChatModel()
const chatProvider = useChatProvider()
const messages = useMessages()
const setMessages = useSetMessages()

Expand All @@ -39,7 +39,7 @@ export const InputArea = ({ character }: { character?: typeof charactersTable.$i
setIsTyping(true)

const { text } = await generateText({
...ollama.chat(chatModel as string),
...ollama.chat(chatProvider.chatModel ?? ''),
messages: msg,
})

Expand Down
43 changes: 24 additions & 19 deletions src/components/settings/settings-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@ import { Icon } from '@iconify/react'
import { Badge, Select, Separator, Text, TextField } from '@radix-ui/themes'
import { useOnline } from '@uiw/react-use-online'

import { useChatModel, useEmbedModel } from '../../hooks/use-model'
import { useChatProvider } from '../../hooks/use-model'
import { useListModels } from '../../hooks/xsai/use-list-models'
import { DebouncedTextField } from '../debounced-textfield.tsx'
import * as Sheet from '../ui/sheet'

export const SettingsChat = () => {
const [chatModel, setChatModel] = useChatModel()
const [embedModel, setEmbedModel] = useEmbedModel()
const chatProvider = useChatProvider()

const isOnline = useOnline()

const badgeColor = isOnline ? 'green' : 'red'
const badgeText = isOnline ? 'Online' : 'Offline'

const baseURL = 'http://localhost:11434/v1/'

const { models } = useListModels({ baseURL })
const { models } = useListModels({ baseURL: chatProvider.baseURL })

return (
<>
Expand All @@ -35,18 +33,21 @@ export const SettingsChat = () => {
{badgeText}
</Badge>
</Text>
<Select.Root defaultValue="ollama">
<Select.Root
onValueChange={p => chatProvider.update({ provider: p as typeof chatProvider.provider })}
value={chatProvider.provider}
>
<Select.Trigger style={{ width: '100%' }} />
<Select.Content position="popper">
<Select.Item value="ollama">
<Icon icon="simple-icons:ollama" inline style={{ marginInlineEnd: '0.5rem' }} />
Ollama (localhost)
</Select.Item>
<Select.Item disabled value="openai">
<Select.Item value="openai">
<Icon icon="simple-icons:openai" inline style={{ marginInlineEnd: '0.5rem' }} />
OpenAI
</Select.Item>
<Select.Item disabled value="openai-compatible">
<Select.Item value="openai-compatible">
<Icon icon="simple-icons:openai" inline style={{ marginInlineEnd: '0.5rem' }} />
OpenAI-compatible
</Select.Item>
Expand All @@ -60,7 +61,11 @@ export const SettingsChat = () => {
<Text as="div" mb="1" weight="bold">
Base URL
</Text>
<DebouncedTextField disabled placeholder="https://openai.com/v1/">
<DebouncedTextField
disabled={!chatProvider.isEditable('baseURL')}
onBlurValueChange={baseURL => chatProvider.update({ baseURL })}
value={chatProvider.baseURL}
>
<TextField.Slot />
</DebouncedTextField>
</label>
Expand All @@ -69,7 +74,11 @@ export const SettingsChat = () => {
<Text as="div" mb="1" weight="bold">
API Key
</Text>
<DebouncedTextField disabled placeholder="sk-abcdefghijklmnop123">
<DebouncedTextField
disabled={!chatProvider.isEditable('apiKey')}
onBlurValueChange={apiKey => chatProvider.update({ apiKey })}
value={chatProvider.apiKey}
>
<TextField.Slot />
</DebouncedTextField>
</label>
Expand All @@ -80,12 +89,10 @@ export const SettingsChat = () => {
<Text as="div" mb="1" weight="bold">
Chat Model
</Text>
<Select.Root defaultValue={chatModel ?? undefined} onValueChange={setChatModel}>
<Select.Root onValueChange={chatModel => chatProvider.update({ chatModel })} value={chatProvider.chatModel ?? undefined}>
<Select.Trigger placeholder="Pick a model" style={{ width: '100%' }} />
<Select.Content position="popper">
{models.map(model => (
<Select.Item key={model.id} value={model.id}>{model.id}</Select.Item>
))}
{models.map(model => (<Select.Item key={model.id} value={model.id}>{model.id}</Select.Item>))}
</Select.Content>
</Select.Root>
</label>
Expand All @@ -94,12 +101,10 @@ export const SettingsChat = () => {
<Text as="div" mb="1" weight="bold">
Embed Model
</Text>
<Select.Root defaultValue={embedModel ?? undefined} onValueChange={setEmbedModel}>
<Select.Root onValueChange={embedModel => chatProvider.update({ embedModel })} value={chatProvider.embedModel ?? undefined}>
<Select.Trigger placeholder="Pick a model" style={{ width: '100%' }} />
<Select.Content position="popper">
{models.map(model => (
<Select.Item key={model.id} value={model.id}>{model.id}</Select.Item>
))}
{models.map(model => (<Select.Item key={model.id} value={model.id}>{model.id}</Select.Item>))}
</Select.Content>
</Select.Root>
</label>
Expand Down
96 changes: 94 additions & 2 deletions src/hooks/use-model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,97 @@
import { useLocalStorage } from 'foxact/use-local-storage'

export const useChatModel = () => useLocalStorage<string>('moetalk/xsai/providers/chat-model')
export interface ChatOptions {
apiKey: string
baseURL: string
chatModel: null | string
embedModel: null | string
provider: 'ollama' | 'openai' | 'openai-compatible'
}

export const useEmbedModel = () => useLocalStorage<string>('moetalk/xsai/providers/embed-model')
interface Configuration {
apiKey?: ConfigurationField<ChatOptions['apiKey']>
baseURL?: ConfigurationField<ChatOptions['baseURL']>
chatModel?: ConfigurationField<ChatOptions['chatModel']>
embedModel?: ConfigurationField<ChatOptions['embedModel']>
}

interface ConfigurationField<T> {
defaultValue: T
/**
* @default true
*/
editable?: boolean
}

type UpdatableFields = Exclude<keyof ChatOptions, 'provider'>

const fieldDefaultValue: ChatOptions = {
apiKey: '',
baseURL: '',
chatModel: null,
embedModel: null,
provider: 'ollama',
}

export type Providers = ChatOptions['provider']

const configurations: Record<Providers, Configuration> = {
'ollama': {
baseURL: {
defaultValue: 'http://localhost:11434/v1/',
},
},
'openai': {
baseURL: {
defaultValue: 'https://api.openai.com/v1/',
editable: false,
},
},
'openai-compatible': {},
}

export interface ChatOptionsState extends ChatOptions {
isEditable: (field: UpdatableFields) => boolean

update: (params: Partial<ChatOptions>) => void
}

const fieldIsEditable = (provider: ChatOptionsState['provider'], field: UpdatableFields): boolean => {
const e = configurations[provider][field]?.editable
return e === undefined ? true : e
}

const mapValidConfiguration = (input: Partial<ChatOptions>): ChatOptions => {
const provider = input.provider ?? fieldDefaultValue.provider
const c = configurations[provider]

const forFieldValue = <K extends UpdatableFields>(field: K, value: ChatOptions[K] | undefined): ChatOptions[K] => {
let defaultValue = c[field]?.defaultValue as ChatOptions[K] | undefined
defaultValue = defaultValue === undefined ? fieldDefaultValue[field] : defaultValue
return value === undefined || !(c[field]?.editable ?? true) ? defaultValue : value
}

return {
apiKey: forFieldValue('apiKey', input.apiKey),
baseURL: forFieldValue('baseURL', input.baseURL),
chatModel: forFieldValue('chatModel', input.chatModel),
embedModel: forFieldValue('embedModel', input.embedModel),
provider,
}
}

export const useChatProvider = (): ChatOptionsState => {
const [_stored, setStored] = useLocalStorage<ChatOptions>('moetalk/xsai/providers/api-options')
const stored = mapValidConfiguration(_stored !== null ? _stored : { provider: 'ollama' })
const update: ChatOptionsState['update'] = (params) => {
// if the provider changes, reset all fields to default
if ((params.provider ?? stored.provider) !== stored.provider) {
setStored(mapValidConfiguration({ ...params }))
}
else {
setStored(mapValidConfiguration({ ...stored, ...params }))
}
}
const isEditable = (field: UpdatableFields) => fieldIsEditable(stored.provider, field)
return { isEditable, update, ...stored }
}
Loading