SettingsPanel.tsx
Language TypeScript17.9 KB536 lines
import { useState } from 'react'import type { Preset } from '../ipc/gen/Preset'import type { Provider } from '../ipc/gen/Provider'import type { Settings } from '../ipc/gen/Settings'import { isMac } from '../lib/theme'import { useStore } from '../state/store'const LOCAL_PRESETS: [string, string][] = [['Ollama', 'http://localhost:11434/v1'],['LM Studio', 'http://localhost:1234/v1'],['llama.cpp', 'http://localhost:8080/v1'],]/** A key press as a global-shortcut string, e.g. "CommandOrControl+Shift+Space". */export function shortcutFrom(e: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean; code: string },mac = isMac,): string | null {const parts: string[] = []if (mac ? e.metaKey : e.ctrlKey) parts.push('CommandOrControl')if (mac && e.ctrlKey) parts.push('Control')if (e.altKey) parts.push('Alt')if (e.shiftKey) parts.push('Shift')const code = e.codelet key: string | null = nullif (/^Key[A-Z]$/.test(code)) key = code.slice(3)else if (/^Digit\d$/.test(code)) key = code.slice(5)else if (/^F\d{1,2}$/.test(code)) key = codeelse if (['Space', 'Enter', 'Tab', 'Backquote', 'Slash', 'Period', 'Comma', 'Semicolon'].includes(code)) key = codeif (!key || parts.length === 0) return nullreturn [...parts, key].join('+')}export function prettyShortcut(s: string, mac = isMac): string {return s.split('+').map((p) =>p === 'CommandOrControl'? mac? '⌘': 'Ctrl': p === 'Shift'? mac? '⇧': 'Shift': p === 'Alt'? mac? '⌥': 'Alt': p === 'Control'? '⌃': p,).join(mac ? '' : '+')}function KeyField({ provider }: { provider: Provider }) {const client = useStore((s) => s.client)const has = useStore((s) => s.keys[provider.id])const refreshProviders = useStore((s) => s.refreshProviders)const refreshModels = useStore((s) => s.refreshModels)const toast = useStore((s) => s.toast)const [key, setKey] = useState('')const [busy, setBusy] = useState(false)const save = async () => {if (!key.trim() || busy) returnsetBusy(true)try {// The key is checked with the provider before it is stored; a refused// key throws and is not stored.const saved = await client?.setApiKey(provider.id, key)setKey('')await refreshProviders()if (saved && !saved.accepted) {toast(`Key saved, but it could not be checked: ${saved.detail ?? 'no reply'}`, 'error')} else {toast(`Key saved. ${provider.name} accepted it.`)void refreshModels(provider.id, true)}} catch (err) {toast(String(err).replace(/^Error: /, ''), 'error')} finally {setBusy(false)}}return (<formclassName="field"onSubmit={(e) => {e.preventDefault()void save()}}><label htmlFor={`key-${provider.id}`}>API key</label><div className="row"><inputid={`key-${provider.id}`}type="password"autoComplete="off"spellCheck={false}placeholder={has ? 'Saved in your keychain' : 'Paste a key'}value={key}onChange={(e) => setKey(e.target.value)}data-testid="api-key"/><button type="submit" className="primary" disabled={!key.trim() || busy}>{busy ? 'Saving...' : 'Save'}</button>{has && (<buttontype="button"className="ghost"onClick={async () => {await client?.clearApiKey(provider.id)await refreshProviders()}}>Remove</button>)}</div><p className="hint">{has ? 'A key is saved. ' : ''}Stored in the {isMac ? 'macOS Keychain' : 'system keychain'}. The interface canreplace or remove it, never read it.</p></form>)}function LocalProviders() {const providers = useStore((s) => s.providers)const models = useStore((s) => s.models)const client = useStore((s) => s.client)const refreshProviders = useStore((s) => s.refreshProviders)const refreshModels = useStore((s) => s.refreshModels)const toast = useStore((s) => s.toast)const [name, setName] = useState('')const [url, setUrl] = useState('')const locals = providers.filter((p) => p.kind !== 'deepinfra')const add = async () => {try {const p = await client!.saveProvider({id: '',kind: 'openai_compatible',name: name.trim() || 'Local',base_url: url.trim(),requires_key: false,})setName('')setUrl('')await refreshProviders()await refreshModels(p.id, true)} catch (err) {toast(String(err), 'error')}}return (<>{locals.map((p) => (<div className="provider-row glass-tint" key={p.id}><div><strong>{p.name}</strong><div className="muted small mono">{p.base_url}</div><div className="muted small">{models[p.id]?.models.length ?? 0} models, free</div></div><div className="row"><button className="ghost small" onClick={() => void refreshModels(p.id, true)}>Refresh</button><buttonclassName="ghost small danger"onClick={async () => {await client?.deleteProvider(p.id)await refreshProviders()}}>Remove</button></div></div>))}<div className="field"><label>Add a local server</label><div className="row wrap">{LOCAL_PRESETS.map(([n, u]) => (<buttonkey={n}className="pill"onClick={() => {setName(n)setUrl(u)}}>{n}</button>))}</div><div className="row"><input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} aria-label="Server name" /><inputclassName="grow mono"placeholder="http://localhost:11434/v1"value={url}onChange={(e) => setUrl(e.target.value)}aria-label="Base URL"/><button className="primary" disabled={!url.trim()} onClick={() => void add()}>Add</button></div><p className="hint">Any OpenAI-compatible server. Local models cost nothing; their token counts are estimated when the serverreports none.</p></div></>)}function Presets() {const presets = useStore((s) => s.presets)const client = useStore((s) => s.client)const toast = useStore((s) => s.toast)const [edit, setEdit] = useState<Preset | null>(null)const reload = async () => useStore.setState({ presets: await client!.listPresets() })const blank: Preset = {id: '',name: '',system_prompt: '',provider_id: null,model_id: null,temperature: null,max_tokens: null,}return (<><ul className="preset-list">{presets.map((p) => (<li key={p.id} className="glass-tint"><div><strong>{p.name}</strong><div className="muted small clamp">{p.system_prompt}</div></div><div className="row"><button className="ghost small" onClick={() => setEdit(p)}>Edit</button><buttonclassName="ghost small danger"onClick={async () => {await client?.deletePreset(p.id)await reload()}}>Delete</button></div></li>))}</ul>{edit ? (<div className="field"><inputplaceholder="Preset name"value={edit.name}onChange={(e) => setEdit({ ...edit, name: e.target.value })}aria-label="Preset name"/><textarearows={4}placeholder="System prompt"value={edit.system_prompt}onChange={(e) => setEdit({ ...edit, system_prompt: e.target.value })}aria-label="System prompt"/><div className="row"><label className="inline">Temperature<inputtype="number"min={0}max={2}step={0.1}value={edit.temperature ?? ''}onChange={(e) =>setEdit({ ...edit, temperature: e.target.value === '' ? null : Number(e.target.value) })}/></label><span className="grow" /><button className="ghost" onClick={() => setEdit(null)}>Cancel</button><buttonclassName="primary"onClick={async () => {try {await client!.savePreset(edit)setEdit(null)await reload()} catch (err) {toast(String(err), 'error')}}}>Save preset</button></div></div>) : (<button className="ghost" onClick={() => setEdit(blank)}>New preset</button>)}</>)}export function SettingsPanel() {const settings = useStore((s) => s.settings)const saveSettings = useStore((s) => s.saveSettings)const providers = useStore((s) => s.providers)const models = useStore((s) => s.models)const info = useStore((s) => s.info)const client = useStore((s) => s.client)const setPanel = useStore((s) => s.setPanel)const [recording, setRecording] = useState(false)if (!settings) return nullconst put = (patch: Partial<Settings>) => void saveSettings({ ...settings, ...patch })const di = providers.find((p) => p.kind === 'deepinfra')const allModels = providers.flatMap((p) => (models[p.id]?.models ?? []).map((m) => ({ p, m })))const modelSelect = (providerKey: 'default_provider_id' | 'quick_provider_id',modelKey: 'default_model_id' | 'quick_model_id',label: string,) => (<label className="field">{label}<selectvalue={`${settings[providerKey]}::${settings[modelKey]}`}onChange={(e) => {const [pid, ...rest] = e.target.value.split('::')put({ [providerKey]: pid, [modelKey]: rest.join('::') } as Partial<Settings>)}}>{!allModels.some(({ p, m }) => p.id === settings[providerKey] && m.id === settings[modelKey]) && (<option value={`${settings[providerKey]}::${settings[modelKey]}`}>{settings[modelKey] || 'Choose'}</option>)}{allModels.map(({ p, m }) => (<option key={`${p.id}::${m.id}`} value={`${p.id}::${m.id}`}>{m.id}{p.kind !== 'deepinfra' ? ` (${p.name})` : ''}</option>))}</select></label>)return (<div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setPanel(null)}><div className="sheet glass-strong" role="dialog" aria-label="Settings"><header className="sheet-head"><h2>Settings</h2><button className="ghost" onClick={() => setPanel(null)}>Done</button></header><div className="sheet-body settings"><section><h3>DeepInfra</h3>{di && <KeyField provider={di} />}</section><section><h3>Local servers</h3><LocalProviders /></section><section><h3>Models</h3>{modelSelect('default_provider_id', 'default_model_id', 'New chats use')}{modelSelect('quick_provider_id', 'quick_model_id', 'The quick prompt uses')}</section><section><h3>Spending</h3><label className="field">Monthly budget (USD)<inputtype="number"min={0}step={1}value={settings.monthly_budget_usd ?? ''}placeholder="No budget"onChange={(e) => put({ monthly_budget_usd: e.target.value === '' ? null : Number(e.target.value) })}/></label><label className="check"><inputtype="checkbox"checked={settings.confirm_over_budget}onChange={(e) => put({ confirm_over_budget: e.target.checked })}/>Ask before sending once the budget is spent</label><p className="hint">Cortex warns at 80% and at 100%. It never blocks a send on its own.</p></section><section><h3>Quick prompt</h3><div className="field">Shortcut<div className="row"><buttonclassName={`shortcut ${recording ? 'recording' : ''}`}onClick={() => setRecording(true)}onBlur={() => setRecording(false)}onKeyDown={(e) => {if (!recording) returne.preventDefault()if (e.key === 'Escape') return setRecording(false)const s = shortcutFrom(e)if (s) {setRecording(false)put({ shortcut: s })}}}data-testid="shortcut">{recording ? 'Press the new shortcut' : prettyShortcut(settings.shortcut)}</button></div></div></section><section><h3>Export</h3><div className="field">Vault folder<div className="row"><span className="path mono grow">{settings.vault_dir ?? 'Not set; you will be asked on the first export'}</span><buttonclassName="ghost"onClick={async () => {const d = await client?.chooseVaultFolder()if (d) put({ vault_dir: d })}}>Choose...</button></div></div><label className="field">Images in exported notes<select value={settings.image_links} onChange={(e) => put({ image_links: e.target.value })}><option value="wiki">Obsidian embeds: ![[image.png]]</option><option value="markdown">Markdown links: </option></select></label></section><section><h3>Appearance</h3><div className="filters" role="radiogroup" aria-label="Theme">{(['system', 'light', 'dark'] as const).map((t) => (<buttonkey={t}role="radio"aria-checked={settings.theme === t}className={`pill ${settings.theme === t ? 'on' : ''}`}onClick={() => put({ theme: t })}>{t[0].toUpperCase() + t.slice(1)}</button>))}</div><label className="check"><inputtype="checkbox"checked={settings.reduce_transparency}onChange={(e) => put({ reduce_transparency: e.target.checked })}/>Reduce transparency (solid panels)</label>{info?.vibrancy && !settings.reduce_transparency && (<><label className="field">Window material<selectdata-testid="material"value={settings.material}onChange={(e) => put({ material: e.target.value })}><option value="sidebar">Sidebar (default)</option><option value="window">Window background</option><option value="fullscreen">Full-screen UI</option><option value="hud">HUD (always dark)</option></select></label><label className="field">Panel tint: {settings.glass_tint}%<inputdata-testid="glass-tint"type="range"min={0}max={100}step={5}value={settings.glass_tint}onChange={(e) => put({ glass_tint: Number(e.target.value) })}/></label><p className="muted small">macOS sets the blur for each material; lower tint lets more of it show.</p></>)}<label className="check"><inputtype="checkbox"checked={settings.send_on_enter}onChange={(e) => put({ send_on_enter: e.target.checked })}/>Enter sends; Shift+Enter for a new line</label></section><section><h3>Presets</h3><Presets /></section><section><h3>About</h3><p className="muted small">Cortex {info?.version}. Chats are stored on this computer only, in{' '}<span className="mono">{info?.data_dir}</span>. No telemetry.</p></section></div></div></div>)}