spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1/*2 * SettingsModal.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Settings: Providers & Keys (masked fields, Test with status dot + latency,9 * proxy base URL, honesty notice), Models (catalog browser, favorites,10 * default), Appearance (light default / dark / system, accents, font size),11 * Data (export/import/clear), Advanced (passphrase lock, default system12 * prompt, streaming toggle).13 */1415import { useMemo, useRef, useState } from 'react'16import { ACCENT_SWATCHES } from '../design/accents'17import { CATALOG, priceBadge } from '../features/catalogHelpers'18import { downloadBackup } from '../features/exporters'19import { testKey } from '../providers/testKey'20import { PROVIDER_META } from '../providers/registry'21import { useStore } from '../state/store'22import { importAll, isExportedData, clearEverything } from '../storage/backup'23import { clearConversations } from '../storage/conversations'24import { getKey, maskedKey, removeKey, setKey } from '../storage/keys'25import { disableVault, enableVault, isVaultEnabled, persistVault } from '../storage/vault'26import { contextBadge, BUILT_IN_PROVIDERS, type Provider } from '../types'27import {28 IconBrain,29 IconEye,30 IconStar,31 IconWrench,32 IconX,33 ProviderGlyph,34} from './icons'3536const TABS = [37 { id: 'providers', label: 'Providers & Keys' },38 { id: 'models', label: 'Models' },39 { id: 'appearance', label: 'Appearance' },40 { id: 'data', label: 'Data' },41 { id: 'advanced', label: 'Advanced' },42]4344type TestState = { status: 'idle' | 'testing' | 'ok' | 'fail'; detail?: string }4546export default function SettingsModal() {47 const settingsTab = useStore((s) => s.settingsTab)48 const closeSettings = useStore((s) => s.closeSettings)49 const [tab, setTab] = useState(settingsTab)5051 return (52 <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && closeSettings()}>53 <div className="panel settings-panel" role="dialog" aria-label="Settings">54 <div className="panel-search" style={{ justifyContent: 'space-between' }}>55 <strong style={{ fontSize: 14 }}>Settings</strong>56 <button className="icon-button" onClick={closeSettings}>57 <IconX />58 </button>59 </div>60 <div className="settings-body">61 <nav className="settings-tabs">62 {TABS.map((t) => (63 <button64 key={t.id}65 className={`settings-tab${tab === t.id ? ' active' : ''}`}66 onClick={() => setTab(t.id)}67 >68 {t.label}69 </button>70 ))}71 </nav>72 <div className="settings-content">73 {tab === 'providers' && <ProvidersTab />}74 {tab === 'models' && <ModelsTab />}75 {tab === 'appearance' && <AppearanceTab />}76 {tab === 'data' && <DataTab />}77 {tab === 'advanced' && <AdvancedTab />}78 </div>79 </div>80 </div>81 </div>82 )83}8485function ProvidersTab() {86 const refreshKeyedProviders = useStore((s) => s.refreshKeyedProviders)87 const settings = useStore((s) => s.settings)88 const updateSettings = useStore((s) => s.updateSettings)89 const [drafts, setDrafts] = useState<Partial<Record<Provider, string>>>({})90 const [tests, setTests] = useState<Partial<Record<Provider, TestState>>>({})91 const [proxyFor, setProxyFor] = useState<Provider | null>(null)92 const [, forceRender] = useState(0)9394 const saveDraft = (provider: Provider) => {95 const draft = drafts[provider]96 if (draft !== undefined && draft !== '') {97 setKey(provider, draft.trim())98 setDrafts((d) => ({ ...d, [provider]: undefined }))99 refreshKeyedProviders()100 forceRender((n) => n + 1)101 }102 }103104 const runTest = async (provider: Provider) => {105 saveDraft(provider)106 const key = getKey(provider)107 if (!key) {108 setTests((t) => ({ ...t, [provider]: { status: 'fail', detail: 'No key' } }))109 return110 }111 setTests((t) => ({ ...t, [provider]: { status: 'testing' } }))112 try {113 const ms = await testKey(provider, key, settings.proxyBaseURLs[provider])114 setTests((t) => ({115 ...t,116 [provider]: { status: 'ok', detail: `Verified · ${Math.round(ms)} ms` },117 }))118 } catch (err) {119 setTests((t) => ({120 ...t,121 [provider]: { status: 'fail', detail: err instanceof Error ? err.message : String(err) },122 }))123 }124 }125126 return (127 <div>128 <div className="first-run-note" style={{ marginBottom: 16 }}>129 <span>130 <strong>Your keys stay in this browser.</strong> They're stored in localStorage on this131 device and sent only to the provider you call. Anyone with access to this browser132 profile (or any script that runs on this page) could read them — that's inherent to a133 backend-less app. Use the optional passphrase lock in Advanced for at-rest encryption.134 </span>135 </div>136 {BUILT_IN_PROVIDERS.map((provider) => {137 const saved = getKey(provider)138 const test = tests[provider] ?? { status: 'idle' }139 const proxy = settings.proxyBaseURLs[provider]140 return (141 <div className="settings-row" key={provider}>142 <span className="provider-name">143 <span style={{ color: 'var(--z-accent)', display: 'inline-flex' }}>144 <ProviderGlyph provider={provider} size={13} />145 </span>146 {PROVIDER_META[provider].displayName}147 <span148 className={`status-dot ${149 test.status === 'ok'150 ? 'ok'151 : test.status === 'fail'152 ? 'fail'153 : saved154 ? 'saved'155 : ''156 }`}157 />158 </span>159 <input160 className="key-input"161 type="password"162 placeholder={saved ? maskedKey(saved) : 'API key'}163 value={drafts[provider] ?? ''}164 onChange={(e) => setDrafts((d) => ({ ...d, [provider]: e.target.value }))}165 onBlur={() => saveDraft(provider)}166 />167 <button className="button" onClick={() => void runTest(provider)}>168 {test.status === 'testing' ? 'Testing…' : 'Test'}169 </button>170 <button171 className="button"172 title="Proxy / base URL override"173 onClick={() => setProxyFor(proxyFor === provider ? null : provider)}174 >175 Proxy{proxy ? ' •' : ''}176 </button>177 {saved && (178 <button179 className="icon-button danger"180 title="Remove key"181 onClick={() => {182 removeKey(provider)183 refreshKeyedProviders()184 setTests((t) => ({ ...t, [provider]: { status: 'idle' } }))185 forceRender((n) => n + 1)186 }}187 >188 <IconX size={10} />189 </button>190 )}191 {test.detail && (192 <span className={`settings-sub ${test.status === 'ok' ? 'ok' : 'fail'}`}>193 {test.detail}194 </span>195 )}196 {proxyFor === provider && (197 <div style={{ width: '100%', display: 'flex', gap: 8, alignItems: 'center' }}>198 <input199 className="text-input"200 style={{ flex: 1 }}201 placeholder={`Proxy base URL (e.g. http://localhost:8787) — default: ${PROVIDER_META[provider].defaultBaseURL}`}202 value={proxy ?? ''}203 onChange={(e) =>204 updateSettings({205 proxyBaseURLs: {206 ...settings.proxyBaseURLs,207 [provider]: e.target.value === '' ? undefined : e.target.value,208 },209 })210 }211 />212 </div>213 )}214 </div>215 )216 })}217 <p className="settings-note" style={{ marginTop: 12 }}>218 All 12 providers currently allow direct browser calls (verified — see docs/CORS-MATRIX.md).219 If a provider starts blocking CORS, set a proxy URL above or run Zyquo Router locally and220 point the override at it.221 </p>222 </div>223 )224}225226function ModelsTab() {227 const settings = useStore((s) => s.settings)228 const updateSettings = useStore((s) => s.updateSettings)229 const [provider, setProvider] = useState<Provider>('openai')230 const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])231 const models = useMemo(() => CATALOG.filter((m) => m.provider === provider), [provider])232233 return (234 <div>235 <div className="settings-row" style={{ borderBottom: 'none' }}>236 <select237 className="select"238 value={provider}239 onChange={(e) => setProvider(e.target.value as Provider)}240 >241 {BUILT_IN_PROVIDERS.map((p) => (242 <option key={p} value={p}>243 {PROVIDER_META[p].displayName}244 </option>245 ))}246 </select>247 <span className="settings-note">{models.length} models</span>248 </div>249 {models.map((model) => (250 <div className="settings-row" key={model.id}>251 <button252 className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}253 style={{ color: favoriteSet.has(model.id) ? 'var(--z-warning)' : 'var(--z-text-tertiary)' }}254 onClick={() =>255 updateSettings({256 favoriteModelIDs: favoriteSet.has(model.id)257 ? settings.favoriteModelIDs.filter((id) => id !== model.id)258 : [...settings.favoriteModelIDs, model.id],259 })260 }261 >262 <IconStar filled={favoriteSet.has(model.id)} />263 </button>264 <div style={{ minWidth: 0, flex: 1 }}>265 <div style={{ fontSize: 13, display: 'flex', gap: 6, alignItems: 'center' }}>266 {model.displayName}267 {model.isRecommended && <span className="badge accent">Featured</span>}268 {model.isLegacy && <span className="badge">Legacy</span>}269 {settings.defaultModelID === model.id && settings.defaultProvider === provider && (270 <span className="badge accent">Default</span>271 )}272 </div>273 <div274 style={{275 fontSize: 10,276 fontFamily: 'var(--z-font-mono)',277 color: 'var(--z-text-tertiary)',278 overflow: 'hidden',279 textOverflow: 'ellipsis',280 whiteSpace: 'nowrap',281 }}282 >283 {model.id}284 </div>285 </div>286 <span className="model-row-badges">287 {model.capabilities.vision && <IconEye />}288 {model.capabilities.reasoning && <IconBrain />}289 {model.capabilities.tools && <IconWrench />}290 <span>{contextBadge(model)}</span>291 <span>{priceBadge(model)}</span>292 </span>293 <button294 className="button"295 onClick={() => updateSettings({ defaultModelID: model.id, defaultProvider: provider })}296 >297 Set default298 </button>299 </div>300 ))}301 </div>302 )303}304305function AppearanceTab() {306 const settings = useStore((s) => s.settings)307 const updateSettings = useStore((s) => s.updateSettings)308 return (309 <div>310 <div className="settings-section">311 <h3>Theme</h3>312 <div style={{ display: 'flex', gap: 8 }}>313 {(['light', 'dark', 'system'] as const).map((theme) => (314 <button315 key={theme}316 className={`button${settings.theme === theme ? ' primary' : ''}`}317 onClick={() => updateSettings({ theme })}318 >319 {theme[0]?.toUpperCase() + theme.slice(1)}320 </button>321 ))}322 </div>323 <p className="settings-note" style={{ marginTop: 6 }}>324 Light is the Zyquo flagship theme and the default.325 </p>326 </div>327 <div className="settings-section">328 <h3>Accent</h3>329 <div style={{ display: 'flex', gap: 8 }}>330 {ACCENT_SWATCHES.map(({ id, swatch }) => (331 <button332 key={id}333 title={id}334 onClick={() => updateSettings({ accent: id })}335 style={{336 width: 28,337 height: 28,338 borderRadius: '50%',339 background: swatch,340 border:341 settings.accent === id342 ? '2px solid var(--z-text-primary)'343 : '2px solid transparent',344 }}345 />346 ))}347 </div>348 </div>349 <div className="settings-section">350 <h3>Chat font size</h3>351 <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>352 <input353 type="range"354 min={12}355 max={18}356 step={0.5}357 value={settings.chatFontSize}358 style={{ flex: 1, accentColor: 'var(--z-accent)' }}359 onChange={(e) => updateSettings({ chatFontSize: Number(e.target.value) })}360 />361 <span style={{ fontSize: 12 }}>{settings.chatFontSize.toFixed(1)} px</span>362 </div>363 <div364 style={{365 marginTop: 8,366 padding: 12,367 background: 'var(--z-surface)',368 border: 'var(--z-hairline) solid var(--z-border)',369 borderRadius: 10,370 fontSize: settings.chatFontSize,371 lineHeight: 1.45,372 }}373 >374 The quick brown fox jumps over the lazy dog.375 </div>376 </div>377 <div className="settings-section">378 <h3>Density</h3>379 <div style={{ display: 'flex', gap: 8 }}>380 {(['comfortable', 'compact'] as const).map((density) => (381 <button382 key={density}383 className={`button${settings.density === density ? ' primary' : ''}`}384 onClick={() => updateSettings({ density })}385 >386 {density[0]?.toUpperCase() + density.slice(1)}387 </button>388 ))}389 </div>390 </div>391 </div>392 )393}394395function DataTab() {396 const toast = useStore((s) => s.toast)397 const loadAll = useStore((s) => s.loadAll)398 const fileRef = useRef<HTMLInputElement>(null)399 const [includeKeys, setIncludeKeys] = useState(false)400 const [confirmClear, setConfirmClear] = useState<string | null>(null)401402 const doImport = async (file: File) => {403 try {404 const parsed: unknown = JSON.parse(await file.text())405 if (!isExportedData(parsed)) throw new Error('Not a Zyquo Cloud Web export file')406 const result = await importAll(parsed)407 await loadAll()408 toast(409 `Imported ${result.conversations} conversations${result.keys > 0 ? ` and ${result.keys} keys` : ''}`,410 'success'411 )412 } catch (err) {413 toast(err instanceof Error ? err.message : 'Import failed', 'error')414 }415 }416417 return (418 <div>419 <div className="settings-section">420 <h3>Export</h3>421 <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}>422 <input423 type="checkbox"424 checked={includeKeys}425 onChange={(e) => setIncludeKeys(e.target.checked)}426 />427 Include API keys428 </label>429 {includeKeys && (430 <p className="settings-note" style={{ color: 'var(--z-warning)' }}>431 ⚠ The exported file will contain your API keys in plain text. Store it safely.432 </p>433 )}434 <button435 className="button primary"436 style={{ marginTop: 8 }}437 onClick={() => void downloadBackup(includeKeys)}438 >439 Download backup (JSON)440 </button>441 </div>442 <div className="settings-section">443 <h3>Import</h3>444 <input445 ref={fileRef}446 type="file"447 accept="application/json"448 hidden449 onChange={(e) => {450 const file = e.target.files?.[0]451 if (file) void doImport(file)452 e.target.value = ''453 }}454 />455 <button className="button" onClick={() => fileRef.current?.click()}>456 Import backup file…457 </button>458 </div>459 <div className="settings-section">460 <h3>Clear data</h3>461 <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>462 {(463 [464 ['conversations', 'Delete all conversations'],465 ['everything', 'Delete everything (keys, history, settings)'],466 ] as const467 ).map(([kind, label]) => (468 <button469 key={kind}470 className="button danger"471 onClick={async () => {472 if (confirmClear !== kind) {473 setConfirmClear(kind)474 setTimeout(() => setConfirmClear(null), 4000)475 return476 }477 if (kind === 'conversations') await clearConversations()478 else await clearEverything()479 await loadAll()480 setConfirmClear(null)481 toast('Cleared', 'success')482 if (kind === 'everything') window.location.reload()483 }}484 >485 {confirmClear === kind ? 'Click again to confirm' : label}486 </button>487 ))}488 </div>489 </div>490 </div>491 )492}493494function AdvancedTab() {495 const settings = useStore((s) => s.settings)496 const updateSettings = useStore((s) => s.updateSettings)497 const toast = useStore((s) => s.toast)498 const [passphrase, setPassphrase] = useState('')499 const vaulted = isVaultEnabled()500501 return (502 <div>503 <div className="settings-section">504 <h3>Default system prompt</h3>505 <textarea506 className="text-input"507 rows={4}508 placeholder="Applied to new conversations"509 value={settings.defaultSystemPrompt}510 onChange={(e) => updateSettings({ defaultSystemPrompt: e.target.value })}511 />512 </div>513 <div className="settings-section">514 <h3>Passphrase lock</h3>515 <p className="settings-note">516 Encrypts your stored keys with AES-GCM (key derived from your passphrase). Honest517 caveat: once unlocked, a compromised page can still read keys — this protects the data518 at rest, not a running session. Without a passphrase, keys are stored in plaintext.519 </p>520 <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>521 <input522 className="text-input"523 type="password"524 placeholder="Passphrase"525 value={passphrase}526 onChange={(e) => setPassphrase(e.target.value)}527 />528 {!vaulted ? (529 <button530 className="button primary"531 disabled={passphrase.length < 4}532 onClick={async () => {533 await enableVault(passphrase)534 setPassphrase('')535 toast('Passphrase lock enabled', 'success')536 }}537 >538 Enable lock539 </button>540 ) : (541 <>542 <button543 className="button"544 disabled={passphrase.length < 4}545 onClick={async () => {546 try {547 await persistVault(passphrase)548 toast('Vault re-encrypted', 'success')549 } catch {550 toast('Wrong passphrase', 'error')551 }552 }}553 >554 Re-encrypt555 </button>556 <button557 className="button danger"558 disabled={passphrase.length < 4}559 onClick={async () => {560 try {561 await disableVault(passphrase)562 setPassphrase('')563 toast('Passphrase lock disabled', 'success')564 } catch {565 toast('Wrong passphrase', 'error')566 }567 }}568 >569 Disable lock570 </button>571 </>572 )}573 </div>574 {vaulted && <p className="settings-note" style={{ marginTop: 6 }}>Lock is enabled.</p>}575 </div>576 <div className="settings-section">577 <h3>Streaming</h3>578 <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}>579 <input580 type="checkbox"581 checked={settings.streamingEnabled}582 onChange={(e) => updateSettings({ streamingEnabled: e.target.checked })}583 />584 Stream responses token-by-token585 </label>586 </div>587 </div>588 )589}590