ProviderSetup.tsx
4,893 bytes
| 1 | import { useState } from 'react' |
|---|---|
| 2 | import type { ConfigStatus } from 'shared/types' |
| 3 | import * as api from '../api' |
| 4 | import { connectionDetail } from '../labels' |
| 5 | |
| 6 | interface ProviderSetupProps { |
| 7 | status: ConfigStatus |
| 8 | onSaved: (status: ConfigStatus) => void |
| 9 | onClose?: () => void |
| 10 | } |
| 11 | |
| 12 | export function ProviderSetup({ status, onSaved, onClose }: ProviderSetupProps) { |
| 13 | const [anthropicKey, setAnthropicKey] = useState('') |
| 14 | const [openaiKey, setOpenaiKey] = useState('') |
| 15 | const [saving, setSaving] = useState(false) |
| 16 | const [error, setError] = useState<string | null>(null) |
| 17 | const [note, setNote] = useState<string | null>(null) |
| 18 | |
| 19 | const anthropicReady = anthropicKey.trim().length > 0 || status.anthropicKeySet |
| 20 | const openaiReady = openaiKey.trim().length > 0 || status.openaiKeySet |
| 21 | |
| 22 | async function save(mode: 'demo' | 'real') { |
| 23 | setError(null) |
| 24 | setNote(null) |
| 25 | setSaving(true) |
| 26 | try { |
| 27 | const result = |
| 28 | mode === 'demo' |
| 29 | ? await api.saveConfig({ mode: 'demo' }) |
| 30 | : await api.saveConfig({ |
| 31 | mode: 'real', |
| 32 | ...(anthropicKey.trim() ? { anthropicKey: anthropicKey.trim() } : {}), |
| 33 | ...(openaiKey.trim() ? { openaiKey: openaiKey.trim() } : {}), |
| 34 | }) |
| 35 | setAnthropicKey('') |
| 36 | setOpenaiKey('') |
| 37 | if (result.restartRequired) { |
| 38 | setNote('Keys saved. Stop this server and run "npm run dev" to use them.') |
| 39 | } else { |
| 40 | onClose?.() |
| 41 | } |
| 42 | onSaved(result.status) |
| 43 | } catch (err) { |
| 44 | setError(err instanceof Error ? err.message : String(err)) |
| 45 | } finally { |
| 46 | setSaving(false) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | return ( |
| 51 | <section className="provider-setup" aria-labelledby="provider-setup-heading"> |
| 52 | <p className="card-kicker">{status.mode === 'unconfigured' ? 'One-time setup' : 'Connection'}</p> |
| 53 | <h2 id="provider-setup-heading"> |
| 54 | {status.mode === 'unconfigured' ? 'Connect VoiceTask' : 'How VoiceTask is connected'} |
| 55 | </h2> |
| 56 | <p className="card-intro">{connectionDetail(status)}</p> |
| 57 | |
| 58 | <form |
| 59 | className="provider-form" |
| 60 | onSubmit={(event) => { |
| 61 | event.preventDefault() |
| 62 | void save('real') |
| 63 | }} |
| 64 | > |
| 65 | <div className="form-field"> |
| 66 | <label htmlFor="anthropic-key">Anthropic key, for the interview</label> |
| 67 | <p id="anthropic-key-help"> |
| 68 | Create one at console.anthropic.com. It starts with sk-ant-. |
| 69 | {status.anthropicKeySet ? ' A key is already saved; leave this empty to keep it.' : ''} |
| 70 | </p> |
| 71 | <input |
| 72 | id="anthropic-key" |
| 73 | type="password" |
| 74 | value={anthropicKey} |
| 75 | onChange={(event) => setAnthropicKey(event.target.value)} |
| 76 | placeholder={status.anthropicKeySet ? 'Saved. Paste a new key to replace it' : 'sk-ant-...'} |
| 77 | autoComplete="off" |
| 78 | spellCheck={false} |
| 79 | aria-describedby="anthropic-key-help" |
| 80 | disabled={saving} |
| 81 | /> |
| 82 | </div> |
| 83 | |
| 84 | <div className="form-field"> |
| 85 | <label htmlFor="openai-key">OpenAI key, for turning speech into text</label> |
| 86 | <p id="openai-key-help"> |
| 87 | Create one at platform.openai.com. It starts with sk-. |
| 88 | {status.openaiKeySet ? ' A key is already saved; leave this empty to keep it.' : ''} |
| 89 | </p> |
| 90 | <input |
| 91 | id="openai-key" |
| 92 | type="password" |
| 93 | value={openaiKey} |
| 94 | onChange={(event) => setOpenaiKey(event.target.value)} |
| 95 | placeholder={status.openaiKeySet ? 'Saved. Paste a new key to replace it' : 'sk-...'} |
| 96 | autoComplete="off" |
| 97 | spellCheck={false} |
| 98 | aria-describedby="openai-key-help" |
| 99 | disabled={saving} |
| 100 | /> |
| 101 | </div> |
| 102 | |
| 103 | <button type="submit" className="btn-primary start-button" disabled={saving || !anthropicReady || !openaiReady}> |
| 104 | {saving ? 'Saving...' : 'Save keys'} |
| 105 | </button> |
| 106 | <p className="form-footnote"> |
| 107 | Keys are written to a local .env file on this computer, and never into your interview or build brief. |
| 108 | </p> |
| 109 | </form> |
| 110 | |
| 111 | <div className="provider-alt"> |
| 112 | <span>or</span> |
| 113 | </div> |
| 114 | |
| 115 | <button type="button" className="choose-folder-button" disabled={saving} onClick={() => void save('demo')}> |
| 116 | Try the demo without keys |
| 117 | </button> |
| 118 | <p className="form-footnote"> |
| 119 | The demo runs the whole flow with built-in sample answers, offline. You can switch to real keys later. |
| 120 | </p> |
| 121 | |
| 122 | {note && ( |
| 123 | <p className="provider-note" role="status"> |
| 124 | {note} |
| 125 | </p> |
| 126 | )} |
| 127 | {error && ( |
| 128 | <p className="error" role="alert"> |
| 129 | {error} |
| 130 | </p> |
| 131 | )} |
| 132 | |
| 133 | {onClose && ( |
| 134 | <button type="button" className="provider-close" onClick={onClose} disabled={saving}> |
| 135 | Back |
| 136 | </button> |
| 137 | )} |
| 138 | </section> |
| 139 | ) |
| 140 | } |
| 141 | |