mattrguerra/cortexmainPrivate on GitHub, published here in full

SettingsPanel.tsx

Language TypeScript17.9 KB536 lines

src/components/SettingsPanel.tsx

  1. import { useState } from 'react'
  2. import type { Preset } from '../ipc/gen/Preset'
  3. import type { Provider } from '../ipc/gen/Provider'
  4. import type { Settings } from '../ipc/gen/Settings'
  5. import { isMac } from '../lib/theme'
  6. import { useStore } from '../state/store'
  7. const LOCAL_PRESETS: [string, string][] = [
  8. ['Ollama', 'http://localhost:11434/v1'],
  9. ['LM Studio', 'http://localhost:1234/v1'],
  10. ['llama.cpp', 'http://localhost:8080/v1'],
  11. ]
  12. /** A key press as a global-shortcut string, e.g. "CommandOrControl+Shift+Space". */
  13. export function shortcutFrom(
  14. e: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean; code: string },
  15. mac = isMac,
  16. ): string | null {
  17. const parts: string[] = []
  18. if (mac ? e.metaKey : e.ctrlKey) parts.push('CommandOrControl')
  19. if (mac && e.ctrlKey) parts.push('Control')
  20. if (e.altKey) parts.push('Alt')
  21. if (e.shiftKey) parts.push('Shift')
  22. const code = e.code
  23. let key: string | null = null
  24. if (/^Key[A-Z]$/.test(code)) key = code.slice(3)
  25. else if (/^Digit\d$/.test(code)) key = code.slice(5)
  26. else if (/^F\d{1,2}$/.test(code)) key = code
  27. else if (['Space', 'Enter', 'Tab', 'Backquote', 'Slash', 'Period', 'Comma', 'Semicolon'].includes(code)) key = code
  28. if (!key || parts.length === 0) return null
  29. return [...parts, key].join('+')
  30. }
  31. export function prettyShortcut(s: string, mac = isMac): string {
  32. return s
  33. .split('+')
  34. .map((p) =>
  35. p === 'CommandOrControl'
  36. ? mac
  37. ? '⌘'
  38. : 'Ctrl'
  39. : p === 'Shift'
  40. ? mac
  41. ? '⇧'
  42. : 'Shift'
  43. : p === 'Alt'
  44. ? mac
  45. ? '⌥'
  46. : 'Alt'
  47. : p === 'Control'
  48. ? '⌃'
  49. : p,
  50. )
  51. .join(mac ? '' : '+')
  52. }
  53. function KeyField({ provider }: { provider: Provider }) {
  54. const client = useStore((s) => s.client)
  55. const has = useStore((s) => s.keys[provider.id])
  56. const refreshProviders = useStore((s) => s.refreshProviders)
  57. const refreshModels = useStore((s) => s.refreshModels)
  58. const toast = useStore((s) => s.toast)
  59. const [key, setKey] = useState('')
  60. const [busy, setBusy] = useState(false)
  61. const save = async () => {
  62. if (!key.trim() || busy) return
  63. setBusy(true)
  64. try {
  65. // The key is checked with the provider before it is stored; a refused
  66. // key throws and is not stored.
  67. const saved = await client?.setApiKey(provider.id, key)
  68. setKey('')
  69. await refreshProviders()
  70. if (saved && !saved.accepted) {
  71. toast(`Key saved, but it could not be checked: ${saved.detail ?? 'no reply'}`, 'error')
  72. } else {
  73. toast(`Key saved. ${provider.name} accepted it.`)
  74. void refreshModels(provider.id, true)
  75. }
  76. } catch (err) {
  77. toast(String(err).replace(/^Error: /, ''), 'error')
  78. } finally {
  79. setBusy(false)
  80. }
  81. }
  82. return (
  83. <form
  84. className="field"
  85. onSubmit={(e) => {
  86. e.preventDefault()
  87. void save()
  88. }}
  89. >
  90. <label htmlFor={`key-${provider.id}`}>API key</label>
  91. <div className="row">
  92. <input
  93. id={`key-${provider.id}`}
  94. type="password"
  95. autoComplete="off"
  96. spellCheck={false}
  97. placeholder={has ? 'Saved in your keychain' : 'Paste a key'}
  98. value={key}
  99. onChange={(e) => setKey(e.target.value)}
  100. data-testid="api-key"
  101. />
  102. <button type="submit" className="primary" disabled={!key.trim() || busy}>
  103. {busy ? 'Saving...' : 'Save'}
  104. </button>
  105. {has && (
  106. <button
  107. type="button"
  108. className="ghost"
  109. onClick={async () => {
  110. await client?.clearApiKey(provider.id)
  111. await refreshProviders()
  112. }}
  113. >
  114. Remove
  115. </button>
  116. )}
  117. </div>
  118. <p className="hint">
  119. {has ? 'A key is saved. ' : ''}Stored in the {isMac ? 'macOS Keychain' : 'system keychain'}. The interface can
  120. replace or remove it, never read it.
  121. </p>
  122. </form>
  123. )
  124. }
  125. function LocalProviders() {
  126. const providers = useStore((s) => s.providers)
  127. const models = useStore((s) => s.models)
  128. const client = useStore((s) => s.client)
  129. const refreshProviders = useStore((s) => s.refreshProviders)
  130. const refreshModels = useStore((s) => s.refreshModels)
  131. const toast = useStore((s) => s.toast)
  132. const [name, setName] = useState('')
  133. const [url, setUrl] = useState('')
  134. const locals = providers.filter((p) => p.kind !== 'deepinfra')
  135. const add = async () => {
  136. try {
  137. const p = await client!.saveProvider({
  138. id: '',
  139. kind: 'openai_compatible',
  140. name: name.trim() || 'Local',
  141. base_url: url.trim(),
  142. requires_key: false,
  143. })
  144. setName('')
  145. setUrl('')
  146. await refreshProviders()
  147. await refreshModels(p.id, true)
  148. } catch (err) {
  149. toast(String(err), 'error')
  150. }
  151. }
  152. return (
  153. <>
  154. {locals.map((p) => (
  155. <div className="provider-row glass-tint" key={p.id}>
  156. <div>
  157. <strong>{p.name}</strong>
  158. <div className="muted small mono">{p.base_url}</div>
  159. <div className="muted small">{models[p.id]?.models.length ?? 0} models, free</div>
  160. </div>
  161. <div className="row">
  162. <button className="ghost small" onClick={() => void refreshModels(p.id, true)}>
  163. Refresh
  164. </button>
  165. <button
  166. className="ghost small danger"
  167. onClick={async () => {
  168. await client?.deleteProvider(p.id)
  169. await refreshProviders()
  170. }}
  171. >
  172. Remove
  173. </button>
  174. </div>
  175. </div>
  176. ))}
  177. <div className="field">
  178. <label>Add a local server</label>
  179. <div className="row wrap">
  180. {LOCAL_PRESETS.map(([n, u]) => (
  181. <button
  182. key={n}
  183. className="pill"
  184. onClick={() => {
  185. setName(n)
  186. setUrl(u)
  187. }}
  188. >
  189. {n}
  190. </button>
  191. ))}
  192. </div>
  193. <div className="row">
  194. <input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} aria-label="Server name" />
  195. <input
  196. className="grow mono"
  197. placeholder="http://localhost:11434/v1"
  198. value={url}
  199. onChange={(e) => setUrl(e.target.value)}
  200. aria-label="Base URL"
  201. />
  202. <button className="primary" disabled={!url.trim()} onClick={() => void add()}>
  203. Add
  204. </button>
  205. </div>
  206. <p className="hint">
  207. Any OpenAI-compatible server. Local models cost nothing; their token counts are estimated when the server
  208. reports none.
  209. </p>
  210. </div>
  211. </>
  212. )
  213. }
  214. function Presets() {
  215. const presets = useStore((s) => s.presets)
  216. const client = useStore((s) => s.client)
  217. const toast = useStore((s) => s.toast)
  218. const [edit, setEdit] = useState<Preset | null>(null)
  219. const reload = async () => useStore.setState({ presets: await client!.listPresets() })
  220. const blank: Preset = {
  221. id: '',
  222. name: '',
  223. system_prompt: '',
  224. provider_id: null,
  225. model_id: null,
  226. temperature: null,
  227. max_tokens: null,
  228. }
  229. return (
  230. <>
  231. <ul className="preset-list">
  232. {presets.map((p) => (
  233. <li key={p.id} className="glass-tint">
  234. <div>
  235. <strong>{p.name}</strong>
  236. <div className="muted small clamp">{p.system_prompt}</div>
  237. </div>
  238. <div className="row">
  239. <button className="ghost small" onClick={() => setEdit(p)}>
  240. Edit
  241. </button>
  242. <button
  243. className="ghost small danger"
  244. onClick={async () => {
  245. await client?.deletePreset(p.id)
  246. await reload()
  247. }}
  248. >
  249. Delete
  250. </button>
  251. </div>
  252. </li>
  253. ))}
  254. </ul>
  255. {edit ? (
  256. <div className="field">
  257. <input
  258. placeholder="Preset name"
  259. value={edit.name}
  260. onChange={(e) => setEdit({ ...edit, name: e.target.value })}
  261. aria-label="Preset name"
  262. />
  263. <textarea
  264. rows={4}
  265. placeholder="System prompt"
  266. value={edit.system_prompt}
  267. onChange={(e) => setEdit({ ...edit, system_prompt: e.target.value })}
  268. aria-label="System prompt"
  269. />
  270. <div className="row">
  271. <label className="inline">
  272. Temperature
  273. <input
  274. type="number"
  275. min={0}
  276. max={2}
  277. step={0.1}
  278. value={edit.temperature ?? ''}
  279. onChange={(e) =>
  280. setEdit({ ...edit, temperature: e.target.value === '' ? null : Number(e.target.value) })
  281. }
  282. />
  283. </label>
  284. <span className="grow" />
  285. <button className="ghost" onClick={() => setEdit(null)}>
  286. Cancel
  287. </button>
  288. <button
  289. className="primary"
  290. onClick={async () => {
  291. try {
  292. await client!.savePreset(edit)
  293. setEdit(null)
  294. await reload()
  295. } catch (err) {
  296. toast(String(err), 'error')
  297. }
  298. }}
  299. >
  300. Save preset
  301. </button>
  302. </div>
  303. </div>
  304. ) : (
  305. <button className="ghost" onClick={() => setEdit(blank)}>
  306. New preset
  307. </button>
  308. )}
  309. </>
  310. )
  311. }
  312. export function SettingsPanel() {
  313. const settings = useStore((s) => s.settings)
  314. const saveSettings = useStore((s) => s.saveSettings)
  315. const providers = useStore((s) => s.providers)
  316. const models = useStore((s) => s.models)
  317. const info = useStore((s) => s.info)
  318. const client = useStore((s) => s.client)
  319. const setPanel = useStore((s) => s.setPanel)
  320. const [recording, setRecording] = useState(false)
  321. if (!settings) return null
  322. const put = (patch: Partial<Settings>) => void saveSettings({ ...settings, ...patch })
  323. const di = providers.find((p) => p.kind === 'deepinfra')
  324. const allModels = providers.flatMap((p) => (models[p.id]?.models ?? []).map((m) => ({ p, m })))
  325. const modelSelect = (
  326. providerKey: 'default_provider_id' | 'quick_provider_id',
  327. modelKey: 'default_model_id' | 'quick_model_id',
  328. label: string,
  329. ) => (
  330. <label className="field">
  331. {label}
  332. <select
  333. value={`${settings[providerKey]}::${settings[modelKey]}`}
  334. onChange={(e) => {
  335. const [pid, ...rest] = e.target.value.split('::')
  336. put({ [providerKey]: pid, [modelKey]: rest.join('::') } as Partial<Settings>)
  337. }}
  338. >
  339. {!allModels.some(({ p, m }) => p.id === settings[providerKey] && m.id === settings[modelKey]) && (
  340. <option value={`${settings[providerKey]}::${settings[modelKey]}`}>{settings[modelKey] || 'Choose'}</option>
  341. )}
  342. {allModels.map(({ p, m }) => (
  343. <option key={`${p.id}::${m.id}`} value={`${p.id}::${m.id}`}>
  344. {m.id}
  345. {p.kind !== 'deepinfra' ? ` (${p.name})` : ''}
  346. </option>
  347. ))}
  348. </select>
  349. </label>
  350. )
  351. return (
  352. <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setPanel(null)}>
  353. <div className="sheet glass-strong" role="dialog" aria-label="Settings">
  354. <header className="sheet-head">
  355. <h2>Settings</h2>
  356. <button className="ghost" onClick={() => setPanel(null)}>
  357. Done
  358. </button>
  359. </header>
  360. <div className="sheet-body settings">
  361. <section>
  362. <h3>DeepInfra</h3>
  363. {di && <KeyField provider={di} />}
  364. </section>
  365. <section>
  366. <h3>Local servers</h3>
  367. <LocalProviders />
  368. </section>
  369. <section>
  370. <h3>Models</h3>
  371. {modelSelect('default_provider_id', 'default_model_id', 'New chats use')}
  372. {modelSelect('quick_provider_id', 'quick_model_id', 'The quick prompt uses')}
  373. </section>
  374. <section>
  375. <h3>Spending</h3>
  376. <label className="field">
  377. Monthly budget (USD)
  378. <input
  379. type="number"
  380. min={0}
  381. step={1}
  382. value={settings.monthly_budget_usd ?? ''}
  383. placeholder="No budget"
  384. onChange={(e) => put({ monthly_budget_usd: e.target.value === '' ? null : Number(e.target.value) })}
  385. />
  386. </label>
  387. <label className="check">
  388. <input
  389. type="checkbox"
  390. checked={settings.confirm_over_budget}
  391. onChange={(e) => put({ confirm_over_budget: e.target.checked })}
  392. />
  393. Ask before sending once the budget is spent
  394. </label>
  395. <p className="hint">Cortex warns at 80% and at 100%. It never blocks a send on its own.</p>
  396. </section>
  397. <section>
  398. <h3>Quick prompt</h3>
  399. <div className="field">
  400. Shortcut
  401. <div className="row">
  402. <button
  403. className={`shortcut ${recording ? 'recording' : ''}`}
  404. onClick={() => setRecording(true)}
  405. onBlur={() => setRecording(false)}
  406. onKeyDown={(e) => {
  407. if (!recording) return
  408. e.preventDefault()
  409. if (e.key === 'Escape') return setRecording(false)
  410. const s = shortcutFrom(e)
  411. if (s) {
  412. setRecording(false)
  413. put({ shortcut: s })
  414. }
  415. }}
  416. data-testid="shortcut"
  417. >
  418. {recording ? 'Press the new shortcut' : prettyShortcut(settings.shortcut)}
  419. </button>
  420. </div>
  421. </div>
  422. </section>
  423. <section>
  424. <h3>Export</h3>
  425. <div className="field">
  426. Vault folder
  427. <div className="row">
  428. <span className="path mono grow">
  429. {settings.vault_dir ?? 'Not set; you will be asked on the first export'}
  430. </span>
  431. <button
  432. className="ghost"
  433. onClick={async () => {
  434. const d = await client?.chooseVaultFolder()
  435. if (d) put({ vault_dir: d })
  436. }}
  437. >
  438. Choose...
  439. </button>
  440. </div>
  441. </div>
  442. <label className="field">
  443. Images in exported notes
  444. <select value={settings.image_links} onChange={(e) => put({ image_links: e.target.value })}>
  445. <option value="wiki">Obsidian embeds: ![[image.png]]</option>
  446. <option value="markdown">Markdown links: ![image](attachments/image.png)</option>
  447. </select>
  448. </label>
  449. </section>
  450. <section>
  451. <h3>Appearance</h3>
  452. <div className="filters" role="radiogroup" aria-label="Theme">
  453. {(['system', 'light', 'dark'] as const).map((t) => (
  454. <button
  455. key={t}
  456. role="radio"
  457. aria-checked={settings.theme === t}
  458. className={`pill ${settings.theme === t ? 'on' : ''}`}
  459. onClick={() => put({ theme: t })}
  460. >
  461. {t[0].toUpperCase() + t.slice(1)}
  462. </button>
  463. ))}
  464. </div>
  465. <label className="check">
  466. <input
  467. type="checkbox"
  468. checked={settings.reduce_transparency}
  469. onChange={(e) => put({ reduce_transparency: e.target.checked })}
  470. />
  471. Reduce transparency (solid panels)
  472. </label>
  473. {info?.vibrancy && !settings.reduce_transparency && (
  474. <>
  475. <label className="field">
  476. Window material
  477. <select
  478. data-testid="material"
  479. value={settings.material}
  480. onChange={(e) => put({ material: e.target.value })}
  481. >
  482. <option value="sidebar">Sidebar (default)</option>
  483. <option value="window">Window background</option>
  484. <option value="fullscreen">Full-screen UI</option>
  485. <option value="hud">HUD (always dark)</option>
  486. </select>
  487. </label>
  488. <label className="field">
  489. Panel tint: {settings.glass_tint}%
  490. <input
  491. data-testid="glass-tint"
  492. type="range"
  493. min={0}
  494. max={100}
  495. step={5}
  496. value={settings.glass_tint}
  497. onChange={(e) => put({ glass_tint: Number(e.target.value) })}
  498. />
  499. </label>
  500. <p className="muted small">macOS sets the blur for each material; lower tint lets more of it show.</p>
  501. </>
  502. )}
  503. <label className="check">
  504. <input
  505. type="checkbox"
  506. checked={settings.send_on_enter}
  507. onChange={(e) => put({ send_on_enter: e.target.checked })}
  508. />
  509. Enter sends; Shift+Enter for a new line
  510. </label>
  511. </section>
  512. <section>
  513. <h3>Presets</h3>
  514. <Presets />
  515. </section>
  516. <section>
  517. <h3>About</h3>
  518. <p className="muted small">
  519. Cortex {info?.version}. Chats are stored on this computer only, in{' '}
  520. <span className="mono">{info?.data_dir}</span>. No telemetry.
  521. </p>
  522. </section>
  523. </div>
  524. </div>
  525. </div>
  526. )
  527. }