SPB Git

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%
20.6 KB · 590 lines tsx
Raw Blame History
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