/*
* SettingsModal.tsx
* Zyquo Cloud Web
*
* Author: Simon-Pierre Boucher
* Mail: contact@spboucher.ai
*
* Settings: Providers & Keys (masked fields, Test with status dot + latency,
* proxy base URL, honesty notice), Models (catalog browser, favorites,
* default), Appearance (light default / dark / system, accents, font size),
* Data (export/import/clear), Advanced (passphrase lock, default system
* prompt, streaming toggle).
*/
import { useMemo, useRef, useState } from 'react'
import { ACCENT_SWATCHES } from '../design/accents'
import { CATALOG, priceBadge } from '../features/catalogHelpers'
import { downloadBackup } from '../features/exporters'
import { testKey } from '../providers/testKey'
import { PROVIDER_META } from '../providers/registry'
import { useStore } from '../state/store'
import { importAll, isExportedData, clearEverything } from '../storage/backup'
import { clearConversations } from '../storage/conversations'
import { getKey, maskedKey, removeKey, setKey } from '../storage/keys'
import { disableVault, enableVault, isVaultEnabled, persistVault } from '../storage/vault'
import { contextBadge, BUILT_IN_PROVIDERS, type Provider } from '../types'
import {
IconBrain,
IconEye,
IconStar,
IconWrench,
IconX,
ProviderGlyph,
} from './icons'
const TABS = [
{ id: 'providers', label: 'Providers & Keys' },
{ id: 'models', label: 'Models' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'data', label: 'Data' },
{ id: 'advanced', label: 'Advanced' },
]
type TestState = { status: 'idle' | 'testing' | 'ok' | 'fail'; detail?: string }
export default function SettingsModal() {
const settingsTab = useStore((s) => s.settingsTab)
const closeSettings = useStore((s) => s.closeSettings)
const [tab, setTab] = useState(settingsTab)
return (
e.target === e.currentTarget && closeSettings()}>
Settings
{TABS.map((t) => (
setTab(t.id)}
>
{t.label}
))}
{tab === 'providers' &&
}
{tab === 'models' &&
}
{tab === 'appearance' &&
}
{tab === 'data' &&
}
{tab === 'advanced' &&
}
)
}
function ProvidersTab() {
const refreshKeyedProviders = useStore((s) => s.refreshKeyedProviders)
const settings = useStore((s) => s.settings)
const updateSettings = useStore((s) => s.updateSettings)
const [drafts, setDrafts] = useState>>({})
const [tests, setTests] = useState>>({})
const [proxyFor, setProxyFor] = useState(null)
const [, forceRender] = useState(0)
const saveDraft = (provider: Provider) => {
const draft = drafts[provider]
if (draft !== undefined && draft !== '') {
setKey(provider, draft.trim())
setDrafts((d) => ({ ...d, [provider]: undefined }))
refreshKeyedProviders()
forceRender((n) => n + 1)
}
}
const runTest = async (provider: Provider) => {
saveDraft(provider)
const key = getKey(provider)
if (!key) {
setTests((t) => ({ ...t, [provider]: { status: 'fail', detail: 'No key' } }))
return
}
setTests((t) => ({ ...t, [provider]: { status: 'testing' } }))
try {
const ms = await testKey(provider, key, settings.proxyBaseURLs[provider])
setTests((t) => ({
...t,
[provider]: { status: 'ok', detail: `Verified · ${Math.round(ms)} ms` },
}))
} catch (err) {
setTests((t) => ({
...t,
[provider]: { status: 'fail', detail: err instanceof Error ? err.message : String(err) },
}))
}
}
return (
Your keys stay in this browser. They're stored in localStorage on this
device and sent only to the provider you call. Anyone with access to this browser
profile (or any script that runs on this page) could read them — that's inherent to a
backend-less app. Use the optional passphrase lock in Advanced for at-rest encryption.
{BUILT_IN_PROVIDERS.map((provider) => {
const saved = getKey(provider)
const test = tests[provider] ?? { status: 'idle' }
const proxy = settings.proxyBaseURLs[provider]
return (
{PROVIDER_META[provider].displayName}
setDrafts((d) => ({ ...d, [provider]: e.target.value }))}
onBlur={() => saveDraft(provider)}
/>
void runTest(provider)}>
{test.status === 'testing' ? 'Testing…' : 'Test'}
setProxyFor(proxyFor === provider ? null : provider)}
>
Proxy{proxy ? ' •' : ''}
{saved && (
{
removeKey(provider)
refreshKeyedProviders()
setTests((t) => ({ ...t, [provider]: { status: 'idle' } }))
forceRender((n) => n + 1)
}}
>
)}
{test.detail && (
{test.detail}
)}
{proxyFor === provider && (
updateSettings({
proxyBaseURLs: {
...settings.proxyBaseURLs,
[provider]: e.target.value === '' ? undefined : e.target.value,
},
})
}
/>
)}
)
})}
All 12 providers currently allow direct browser calls (verified — see docs/CORS-MATRIX.md).
If a provider starts blocking CORS, set a proxy URL above or run Zyquo Router locally and
point the override at it.
)
}
function ModelsTab() {
const settings = useStore((s) => s.settings)
const updateSettings = useStore((s) => s.updateSettings)
const [provider, setProvider] = useState('openai')
const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])
const models = useMemo(() => CATALOG.filter((m) => m.provider === provider), [provider])
return (
setProvider(e.target.value as Provider)}
>
{BUILT_IN_PROVIDERS.map((p) => (
{PROVIDER_META[p].displayName}
))}
{models.length} models
{models.map((model) => (
updateSettings({
favoriteModelIDs: favoriteSet.has(model.id)
? settings.favoriteModelIDs.filter((id) => id !== model.id)
: [...settings.favoriteModelIDs, model.id],
})
}
>
{model.displayName}
{model.isRecommended && Featured }
{model.isLegacy && Legacy }
{settings.defaultModelID === model.id && settings.defaultProvider === provider && (
Default
)}
{model.id}
{model.capabilities.vision && }
{model.capabilities.reasoning && }
{model.capabilities.tools && }
{contextBadge(model)}
{priceBadge(model)}
updateSettings({ defaultModelID: model.id, defaultProvider: provider })}
>
Set default
))}
)
}
function AppearanceTab() {
const settings = useStore((s) => s.settings)
const updateSettings = useStore((s) => s.updateSettings)
return (
Theme
{(['light', 'dark', 'system'] as const).map((theme) => (
updateSettings({ theme })}
>
{theme[0]?.toUpperCase() + theme.slice(1)}
))}
Light is the Zyquo flagship theme and the default.
Accent
{ACCENT_SWATCHES.map(({ id, swatch }) => (
updateSettings({ accent: id })}
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: swatch,
border:
settings.accent === id
? '2px solid var(--z-text-primary)'
: '2px solid transparent',
}}
/>
))}
Density
{(['comfortable', 'compact'] as const).map((density) => (
updateSettings({ density })}
>
{density[0]?.toUpperCase() + density.slice(1)}
))}
)
}
function DataTab() {
const toast = useStore((s) => s.toast)
const loadAll = useStore((s) => s.loadAll)
const fileRef = useRef(null)
const [includeKeys, setIncludeKeys] = useState(false)
const [confirmClear, setConfirmClear] = useState(null)
const doImport = async (file: File) => {
try {
const parsed: unknown = JSON.parse(await file.text())
if (!isExportedData(parsed)) throw new Error('Not a Zyquo Cloud Web export file')
const result = await importAll(parsed)
await loadAll()
toast(
`Imported ${result.conversations} conversations${result.keys > 0 ? ` and ${result.keys} keys` : ''}`,
'success'
)
} catch (err) {
toast(err instanceof Error ? err.message : 'Import failed', 'error')
}
}
return (
)
}
function AdvancedTab() {
const settings = useStore((s) => s.settings)
const updateSettings = useStore((s) => s.updateSettings)
const toast = useStore((s) => s.toast)
const [passphrase, setPassphrase] = useState('')
const vaulted = isVaultEnabled()
return (
Default system prompt
Passphrase lock
Encrypts your stored keys with AES-GCM (key derived from your passphrase). Honest
caveat: once unlocked, a compromised page can still read keys — this protects the data
at rest, not a running session. Without a passphrase, keys are stored in plaintext.
setPassphrase(e.target.value)}
/>
{!vaulted ? (
{
await enableVault(passphrase)
setPassphrase('')
toast('Passphrase lock enabled', 'success')
}}
>
Enable lock
) : (
<>
{
try {
await persistVault(passphrase)
toast('Vault re-encrypted', 'success')
} catch {
toast('Wrong passphrase', 'error')
}
}}
>
Re-encrypt
{
try {
await disableVault(passphrase)
setPassphrase('')
toast('Passphrase lock disabled', 'success')
} catch {
toast('Wrong passphrase', 'error')
}
}}
>
Disable lock
>
)}
{vaulted &&
Lock is enabled.
}
Streaming
updateSettings({ streamingEnabled: e.target.checked })}
/>
Stream responses token-by-token
)
}