Commit
Add API keys from the app instead of the terminal
commit
3e4c5e6
27 changed files with +997 and −117
Jump to a changed file
- .env.example +4 −2
- README.md +12 −9
- client/src/App.css +107 −1
- client/src/App.tsx +99 −67
- client/src/api.ts +14 −0
- client/src/components/ProviderSetup.tsx +140 −0
- client/src/labels.test.ts +36 −0
- client/src/labels.ts +21 −1
- scripts/setup.mjs +4 −1
- server/app.ts +16 −3
- server/config/runtimeConfig.test.ts +123 −0
- server/config/runtimeConfig.ts +104 −0
- server/index.ts +11 −18
- server/providers/factory.ts +15 −0
- server/providers/llmAnthropic.ts +4 −3
- server/providers/sttOpenai.ts +4 −3
- server/providers/types.ts +6 −0
- server/routes/audio.ts +12 −1
- server/routes/config.test.ts +135 −0
- server/routes/config.ts +62 −0
- server/routes/generate.ts +4 −1
- server/routes/sessions.ts +4 −1
- shared/types.ts +35 −0
- spec/PLAN.md +7 −4
- spec/SPEC.md +11 −1
- spec/TASKS.md +5 −0
- spec/VERIFICATION.md +2 −1
modified .env.example +4 −2
| @@ -1,5 +1,7 @@ | ||
| 1 | -# Try the app without keys by running "npm run demo". It overrides this file for that run. | |
| 2 | -# For real providers, run "npm run setup" and it writes .env for you. | |
| 1 | +# You do not need this file to start. Run "npm run dev" and the first screen in the | |
| 2 | +# browser asks for your keys or offers the offline demo, then writes .env for you. | |
| 3 | +# "npm run setup" asks the same questions in the terminal. | |
| 4 | +# "npm run demo" always uses offline mocks and overrides this file for that run. | |
| 3 | 5 | # To configure manually, copy this file to .env and replace the key placeholders. |
| 4 | 6 | |
| 5 | 7 | # 1 = deterministic offline mocks for STT and LLM (used by tests and CI) |
modified README.md +12 −9
| @@ -6,30 +6,32 @@You speak or type. VoiceTask asks one focused question at a time, tracks which p | ||
| 6 | 6 | |
| 7 | 7 | VoiceTask runs on your computer. It needs no account and saves each interview locally. |
| 8 | 8 | |
| 9 | -## Try it without API keys | |
| 9 | +## Start it | |
| 10 | 10 | |
| 11 | 11 | ``` |
| 12 | 12 | npm install |
| 13 | -npm run demo | |
| 13 | +npm run dev | |
| 14 | 14 | ``` |
| 15 | 15 | |
| 16 | -Open the printed Vite URL in Chrome or Edge. | |
| 16 | +Open the printed Vite URL in Chrome or Edge. The first screen asks how you want to run it: | |
| 17 | + | |
| 18 | +- **Paste your own keys.** An Anthropic key for the interview (console.anthropic.com) and an OpenAI key for speech-to-text (platform.openai.com). VoiceTask saves them to a local `.env` file and starts using them right away, without a restart. | |
| 19 | +- **Try the demo without keys.** The whole flow runs on built-in sample answers, offline. | |
| 17 | 20 | |
| 18 | -Demo mode uses deterministic mock speech and interview providers. It does not need API keys and does not call a provider, even if an existing `.env` selects real providers. It is intended for trying the complete flow safely. | |
| 21 | +You can switch between the two at any time from the button in the top right of the home screen. | |
| 19 | 22 | |
| 20 | -## Use it with real speech and interview responses | |
| 23 | +Two other ways to configure it, if you prefer the terminal: | |
| 21 | 24 | |
| 22 | 25 | ``` |
| 23 | -npm run setup | |
| 24 | -npm run dev | |
| 26 | +npm run demo # always offline, ignores whatever .env selects | |
| 27 | +npm run setup # asks the same questions in the terminal and writes .env | |
| 25 | 28 | ``` |
| 26 | 29 | |
| 27 | -`npm run setup` asks you to choose real APIs or the offline demo. For real mode, it asks for an Anthropic key for the interview and an OpenAI key for speech-to-text, then writes a local `.env`. | |
| 28 | - | |
| 29 | 30 | You can also copy `.env.example` to `.env` and fill in the values yourself. |
| 30 | 31 | |
| 31 | 32 | ## How a session works |
| 32 | 33 | |
| 34 | +0. On first run, choose your keys or the offline demo. | |
| 33 | 35 | 1. Give the idea a short name and choose the project folder. |
| 34 | 36 | 2. Click `Start talking`, speak, then click `Send recording`. You can type instead, or hold Space while you talk. |
| 35 | 37 | 3. Answer one question at a time. The progress panel shows what is ready, in progress, and still to discuss. |
| @@ -58,6 +60,7 @@A source marker such as `[S7]` means the requirement came from interview segment | ||
| 58 | 60 | - The completed brief and transcript can be downloaded from the app. |
| 59 | 61 | - In real mode, audio is sent to OpenAI for transcription and interview text is sent to Anthropic for interview and file generation. |
| 60 | 62 | - API keys are read from environment variables or the local `.env`. They are not written to sessions, generated files, or logs. |
| 63 | +- Keys entered in the app go to the same local `.env`, which is gitignored. The app never sends a key back to the browser; the settings screen shows only the last four characters of a saved key. | |
| 61 | 64 | |
| 62 | 65 | ## Providers and models |
| 63 | 66 |
modified client/src/App.css +107 −1
| @@ -202,6 +202,7 @@ | ||
| 202 | 202 | } |
| 203 | 203 | |
| 204 | 204 | .create-form input, |
| 205 | +.provider-form input, | |
| 205 | 206 | .folder-field input { |
| 206 | 207 | width: 100%; |
| 207 | 208 | padding: 12px 14px; |
| @@ -214,13 +215,15 @@ | ||
| 214 | 215 | } |
| 215 | 216 | |
| 216 | 217 | .create-form input:focus, |
| 218 | +.provider-form input:focus, | |
| 217 | 219 | .folder-field input:focus { |
| 218 | 220 | outline: none; |
| 219 | 221 | border-color: var(--clay); |
| 220 | 222 | box-shadow: 0 0 0 4px color-mix(in srgb, var(--clay) 10%, transparent); |
| 221 | 223 | } |
| 222 | 224 | |
| 223 | -.create-form input::placeholder { | |
| 225 | +.create-form input::placeholder, | |
| 226 | +.provider-form input::placeholder { | |
| 224 | 227 | color: var(--ink-soft); |
| 225 | 228 | opacity: 0.62; |
| 226 | 229 | } |
| @@ -319,6 +322,109 @@ | ||
| 319 | 322 | text-align: left; |
| 320 | 323 | } |
| 321 | 324 | |
| 325 | +.home-nav-status { | |
| 326 | + display: flex; | |
| 327 | + flex-wrap: wrap; | |
| 328 | + align-items: center; | |
| 329 | + gap: 10px; | |
| 330 | +} | |
| 331 | + | |
| 332 | +.connection-chip { | |
| 333 | + display: inline-flex; | |
| 334 | + align-items: center; | |
| 335 | + gap: 8px; | |
| 336 | + padding: 8px 12px; | |
| 337 | + border: 1px solid var(--line); | |
| 338 | + border-radius: 999px; | |
| 339 | + background: color-mix(in srgb, var(--card) 80%, transparent); | |
| 340 | + color: var(--ink-soft); | |
| 341 | + font-size: 13px; | |
| 342 | + font-weight: 600; | |
| 343 | +} | |
| 344 | + | |
| 345 | +.connection-chip::before { | |
| 346 | + content: ''; | |
| 347 | + width: 8px; | |
| 348 | + height: 8px; | |
| 349 | + border-radius: 50%; | |
| 350 | + background: var(--clay); | |
| 351 | +} | |
| 352 | + | |
| 353 | +.connection-chip.mode-demo::before { | |
| 354 | + background: var(--amber); | |
| 355 | +} | |
| 356 | + | |
| 357 | +.connection-chip.mode-unconfigured { | |
| 358 | + border-color: color-mix(in srgb, var(--pulse) 45%, var(--line)); | |
| 359 | + background: var(--pulse-soft); | |
| 360 | + color: var(--ink); | |
| 361 | +} | |
| 362 | + | |
| 363 | +.connection-chip.mode-unconfigured::before { | |
| 364 | + background: var(--pulse); | |
| 365 | +} | |
| 366 | + | |
| 367 | +button.connection-chip { | |
| 368 | + cursor: pointer; | |
| 369 | + transition: border-color 0.2s ease, color 0.2s ease; | |
| 370 | +} | |
| 371 | + | |
| 372 | +button.connection-chip:hover { | |
| 373 | + border-color: var(--clay); | |
| 374 | + color: var(--clay-deep); | |
| 375 | +} | |
| 376 | + | |
| 377 | +.provider-form { | |
| 378 | + display: flex; | |
| 379 | + flex-direction: column; | |
| 380 | + gap: 22px; | |
| 381 | + margin-top: 26px; | |
| 382 | +} | |
| 383 | + | |
| 384 | +.provider-alt { | |
| 385 | + display: flex; | |
| 386 | + align-items: center; | |
| 387 | + gap: 12px; | |
| 388 | + margin: 22px 0 16px; | |
| 389 | + color: var(--ink-soft); | |
| 390 | + font-size: 12px; | |
| 391 | + font-weight: 600; | |
| 392 | + text-transform: uppercase; | |
| 393 | + letter-spacing: 0.08em; | |
| 394 | +} | |
| 395 | + | |
| 396 | +.provider-alt::before, | |
| 397 | +.provider-alt::after { | |
| 398 | + content: ''; | |
| 399 | + flex: 1; | |
| 400 | + height: 1px; | |
| 401 | + background: var(--line); | |
| 402 | +} | |
| 403 | + | |
| 404 | +.provider-note { | |
| 405 | + margin-top: 14px; | |
| 406 | + padding: 10px 12px; | |
| 407 | + border-radius: 10px; | |
| 408 | + background: color-mix(in srgb, var(--amber) 10%, var(--card)); | |
| 409 | + color: var(--ink); | |
| 410 | + font-size: 13px; | |
| 411 | +} | |
| 412 | + | |
| 413 | +.provider-close { | |
| 414 | + margin-top: 18px; | |
| 415 | + padding: 0; | |
| 416 | + border: none; | |
| 417 | + background: none; | |
| 418 | + color: var(--clay-deep); | |
| 419 | + font-size: 14px; | |
| 420 | + font-weight: 700; | |
| 421 | + cursor: pointer; | |
| 422 | +} | |
| 423 | + | |
| 424 | +.provider-close:hover { | |
| 425 | + text-decoration: underline; | |
| 426 | +} | |
| 427 | + | |
| 322 | 428 | .outcome-preview { |
| 323 | 429 | position: relative; |
| 324 | 430 | display: grid; |
modified client/src/App.tsx +99 −67
| @@ -1,18 +1,20 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 2 | -import type { Session, SessionSummary } from 'shared/types' | |
| 2 | +import type { ConfigStatus, Session, SessionSummary } from 'shared/types' | |
| 3 | 3 | import * as api from './api' |
| 4 | 4 | import './App.css' |
| 5 | 5 | import { BlockerImport } from './components/BlockerImport' |
| 6 | 6 | import { CoveragePanel } from './components/CoveragePanel' |
| 7 | 7 | import { FolderBrowser } from './components/FolderBrowser' |
| 8 | 8 | import { GeneratePanel } from './components/GeneratePanel' |
| 9 | 9 | import { OutcomePreview } from './components/OutcomePreview' |
| 10 | +import { ProviderSetup } from './components/ProviderSetup' | |
| 10 | 11 | import { PushToTalkButton } from './components/PushToTalkButton' |
| 11 | 12 | import { QuestionCard } from './components/QuestionCard' |
| 12 | 13 | import { Transcript } from './components/Transcript' |
| 13 | 14 | import { TranscriptExport } from './components/TranscriptExport' |
| 14 | 15 | import { VoiceOrb, type VoiceState } from './components/VoiceOrb' |
| 15 | 16 | import { Waveform, type WaveformMode } from './components/Waveform' |
| 17 | +import { connectionLabel } from './labels' | |
| 16 | 18 | import { speak, stopSpeaking } from './tts' |
| 17 | 19 | |
| 18 | 20 | function latestQuestion(session: Session): string | null { |
| @@ -41,9 +43,12 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | ||
| 41 | 43 | const [error, setError] = useState<string | null>(null) |
| 42 | 44 | const [creating, setCreating] = useState(false) |
| 43 | 45 | const [browsingFolder, setBrowsingFolder] = useState(false) |
| 46 | + const [config, setConfig] = useState<ConfigStatus | null>(null) | |
| 47 | + const [editingConnection, setEditingConnection] = useState(false) | |
| 44 | 48 | |
| 45 | 49 | useEffect(() => { |
| 46 | 50 | api.listSessions().then(setSessions).catch((err: Error) => setError(err.message)) |
| 51 | + api.getConfig().then(setConfig).catch((err: Error) => setError(err.message)) | |
| 47 | 52 | }, []) |
| 48 | 53 | |
| 49 | 54 | async function handleDelete(id: string) { |
| @@ -71,16 +76,33 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | ||
| 71 | 76 | } |
| 72 | 77 | } |
| 73 | 78 | |
| 79 | + const showSetup = config !== null && (config.mode === 'unconfigured' || editingConnection) | |
| 80 | + | |
| 74 | 81 | return ( |
| 75 | 82 | <div className="screen home-screen"> |
| 76 | 83 | <header className="home-nav"> |
| 77 | 84 | <span className="wordmark"> |
| 78 | 85 | voicetask<span className="wordmark-dot">.</span> |
| 79 | 86 | </span> |
| 80 | - <span className="local-badge"> | |
| 81 | - <span aria-hidden="true" /> | |
| 82 | - Saved on this computer | |
| 83 | - </span> | |
| 87 | + <div className="home-nav-status"> | |
| 88 | + <span className="local-badge"> | |
| 89 | + <span aria-hidden="true" /> | |
| 90 | + Saved on this computer | |
| 91 | + </span> | |
| 92 | + {config && config.mode === 'unconfigured' && ( | |
| 93 | + <span className="connection-chip mode-unconfigured">{connectionLabel(config)}</span> | |
| 94 | + )} | |
| 95 | + {config && config.mode !== 'unconfigured' && ( | |
| 96 | + <button | |
| 97 | + type="button" | |
| 98 | + className={`connection-chip mode-${config.mode}`} | |
| 99 | + aria-expanded={editingConnection} | |
| 100 | + onClick={() => setEditingConnection((open) => !open)} | |
| 101 | + > | |
| 102 | + {connectionLabel(config)} | |
| 103 | + </button> | |
| 104 | + )} | |
| 105 | + </div> | |
| 84 | 106 | </header> |
| 85 | 107 | |
| 86 | 108 | <main className="home-main"> |
| @@ -103,74 +125,84 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | ||
| 103 | 125 | <OutcomePreview /> |
| 104 | 126 | </section> |
| 105 | 127 | |
| 106 | - <section className="start-card" aria-labelledby="start-heading"> | |
| 107 | - <p className="card-kicker">Start here</p> | |
| 108 | - <h2 id="start-heading">What are you working on?</h2> | |
| 109 | - <p className="card-intro">A rough idea is enough. The interview will help fill in the rest.</p> | |
| 110 | - | |
| 111 | - <form className="create-form" onSubmit={handleCreate}> | |
| 112 | - <div className="form-field"> | |
| 113 | - <label htmlFor="project-name">Name your idea</label> | |
| 114 | - <p id="project-name-help">Use a short name so you can find this interview later.</p> | |
| 115 | - <input | |
| 116 | - id="project-name" | |
| 117 | - value={name} | |
| 118 | - onChange={(event) => setName(event.target.value)} | |
| 119 | - placeholder="Family recipe app" | |
| 120 | - aria-describedby="project-name-help" | |
| 121 | - required | |
| 122 | - /> | |
| 123 | - </div> | |
| 124 | - | |
| 125 | - <div className="form-field"> | |
| 126 | - <span className="field-label" id="project-folder-label"> | |
| 127 | - Choose where to save the build brief | |
| 128 | - </span> | |
| 129 | - <p id="project-folder-help"> | |
| 130 | - VoiceTask writes the finished files here. You can also download them as a zip. | |
| 131 | - </p> | |
| 132 | - <button | |
| 133 | - type="button" | |
| 134 | - className="choose-folder-button" | |
| 135 | - onClick={() => setBrowsingFolder(true)} | |
| 136 | - aria-describedby="project-folder-help" | |
| 137 | - > | |
| 138 | - {targetDir ? 'Choose a different folder' : 'Choose a folder'} | |
| 139 | - </button> | |
| 140 | - {targetDir && ( | |
| 141 | - <p className="selected-folder" title={targetDir}> | |
| 142 | - <span>Selected</span> | |
| 143 | - {targetDir} | |
| 144 | - </p> | |
| 145 | - )} | |
| 146 | - <label className="path-label" htmlFor="project-folder"> | |
| 147 | - Or enter a folder path | |
| 148 | - </label> | |
| 149 | - <div className="folder-field"> | |
| 128 | + {showSetup && config ? ( | |
| 129 | + <section className="start-card"> | |
| 130 | + <ProviderSetup | |
| 131 | + status={config} | |
| 132 | + onSaved={setConfig} | |
| 133 | + onClose={config.mode === 'unconfigured' ? undefined : () => setEditingConnection(false)} | |
| 134 | + /> | |
| 135 | + </section> | |
| 136 | + ) : ( | |
| 137 | + <section className="start-card" aria-labelledby="start-heading"> | |
| 138 | + <p className="card-kicker">Start here</p> | |
| 139 | + <h2 id="start-heading">What are you working on?</h2> | |
| 140 | + <p className="card-intro">A rough idea is enough. The interview will help fill in the rest.</p> | |
| 141 | + | |
| 142 | + <form className="create-form" onSubmit={handleCreate}> | |
| 143 | + <div className="form-field"> | |
| 144 | + <label htmlFor="project-name">Name your idea</label> | |
| 145 | + <p id="project-name-help">Use a short name so you can find this interview later.</p> | |
| 150 | 146 | <input |
| 151 | - id="project-folder" | |
| 152 | - value={targetDir} | |
| 153 | - onChange={(event) => setTargetDir(event.target.value)} | |
| 154 | - placeholder="Paste an absolute folder path" | |
| 155 | - aria-labelledby="project-folder-label" | |
| 156 | - aria-describedby="project-folder-help" | |
| 147 | + id="project-name" | |
| 148 | + value={name} | |
| 149 | + onChange={(event) => setName(event.target.value)} | |
| 150 | + placeholder="Family recipe app" | |
| 151 | + aria-describedby="project-name-help" | |
| 157 | 152 | required |
| 158 | 153 | /> |
| 159 | 154 | </div> |
| 160 | - </div> | |
| 161 | 155 | |
| 162 | - <button type="submit" className="btn-primary start-button" disabled={creating}> | |
| 163 | - {creating ? 'Starting...' : 'Start my interview'} | |
| 164 | - </button> | |
| 165 | - <p className="form-footnote">Answer by voice or typing. Stop whenever you have said enough.</p> | |
| 166 | - </form> | |
| 156 | + <div className="form-field"> | |
| 157 | + <span className="field-label" id="project-folder-label"> | |
| 158 | + Choose where to save the build brief | |
| 159 | + </span> | |
| 160 | + <p id="project-folder-help"> | |
| 161 | + VoiceTask writes the finished files here. You can also download them as a zip. | |
| 162 | + </p> | |
| 163 | + <button | |
| 164 | + type="button" | |
| 165 | + className="choose-folder-button" | |
| 166 | + onClick={() => setBrowsingFolder(true)} | |
| 167 | + aria-describedby="project-folder-help" | |
| 168 | + > | |
| 169 | + {targetDir ? 'Choose a different folder' : 'Choose a folder'} | |
| 170 | + </button> | |
| 171 | + {targetDir && ( | |
| 172 | + <p className="selected-folder" title={targetDir}> | |
| 173 | + <span>Selected</span> | |
| 174 | + {targetDir} | |
| 175 | + </p> | |
| 176 | + )} | |
| 177 | + <label className="path-label" htmlFor="project-folder"> | |
| 178 | + Or enter a folder path | |
| 179 | + </label> | |
| 180 | + <div className="folder-field"> | |
| 181 | + <input | |
| 182 | + id="project-folder" | |
| 183 | + value={targetDir} | |
| 184 | + onChange={(event) => setTargetDir(event.target.value)} | |
| 185 | + placeholder="Paste an absolute folder path" | |
| 186 | + aria-labelledby="project-folder-label" | |
| 187 | + aria-describedby="project-folder-help" | |
| 188 | + required | |
| 189 | + /> | |
| 190 | + </div> | |
| 191 | + </div> | |
| 167 | 192 | |
| 168 | - {error && ( | |
| 169 | - <p className="error" role="alert"> | |
| 170 | - {error} | |
| 171 | - </p> | |
| 172 | - )} | |
| 173 | - </section> | |
| 193 | + <button type="submit" className="btn-primary start-button" disabled={creating}> | |
| 194 | + {creating ? 'Starting...' : 'Start my interview'} | |
| 195 | + </button> | |
| 196 | + <p className="form-footnote">Answer by voice or typing. Stop whenever you have said enough.</p> | |
| 197 | + </form> | |
| 198 | + | |
| 199 | + {error && ( | |
| 200 | + <p className="error" role="alert"> | |
| 201 | + {error} | |
| 202 | + </p> | |
| 203 | + )} | |
| 204 | + </section> | |
| 205 | + )} | |
| 174 | 206 | </main> |
| 175 | 207 | |
| 176 | 208 | <section className="process-section" aria-labelledby="process-heading"> |
modified client/src/api.ts +14 −0
| @@ -2,6 +2,9 @@import type { | ||
| 2 | 2 | AnswerRequest, |
| 3 | 3 | AnswerResponse, |
| 4 | 4 | BlockersResponse, |
| 5 | + ConfigStatus, | |
| 6 | + ConfigUpdateRequest, | |
| 7 | + ConfigUpdateResponse, | |
| 5 | 8 | CreateSessionRequest, |
| 6 | 9 | FsBrowseResponse, |
| 7 | 10 | FsMkdirResponse, |
| @@ -84,6 +87,17 @@export function importBlockers(id: string): Promise<BlockersResponse> { | ||
| 84 | 87 | }) |
| 85 | 88 | } |
| 86 | 89 | |
| 90 | +export function getConfig(): Promise<ConfigStatus> { | |
| 91 | + return requestJson<ConfigStatus>(`${BASE}/config`) | |
| 92 | +} | |
| 93 | + | |
| 94 | +export function saveConfig(req: ConfigUpdateRequest): Promise<ConfigUpdateResponse> { | |
| 95 | + return requestJson<ConfigUpdateResponse>(`${BASE}/config`, { | |
| 96 | + method: 'POST', | |
| 97 | + body: JSON.stringify(req), | |
| 98 | + }) | |
| 99 | +} | |
| 100 | + | |
| 87 | 101 | export function browseFolder(path?: string): Promise<FsBrowseResponse> { |
| 88 | 102 | const query = path ? `?path=${encodeURIComponent(path)}` : '' |
| 89 | 103 | return requestJson<FsBrowseResponse>(`${BASE}/fs/browse${query}`) |
added client/src/components/ProviderSetup.tsx +140 −0
| @@ -0,0 +1,140 @@ | ||
| 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 | +} |
added client/src/labels.test.ts +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +import type { ConfigStatus } from 'shared/types' | |
| 2 | +import { describe, expect, it } from 'vitest' | |
| 3 | +import { connectionDetail, connectionLabel } from './labels' | |
| 4 | + | |
| 5 | +function status(overrides: Partial<ConfigStatus> = {}): ConfigStatus { | |
| 6 | + return { | |
| 7 | + mode: 'unconfigured', | |
| 8 | + demoLocked: false, | |
| 9 | + anthropicKeySet: false, | |
| 10 | + openaiKeySet: false, | |
| 11 | + anthropicKeyHint: null, | |
| 12 | + openaiKeyHint: null, | |
| 13 | + ...overrides, | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +describe('connection labels', () => { | |
| 18 | + it('names each provider mode in plain language', () => { | |
| 19 | + expect(connectionLabel(status())).toBe('Setup needed') | |
| 20 | + expect(connectionLabel(status({ mode: 'demo' }))).toBe('Demo mode') | |
| 21 | + expect(connectionLabel(status({ mode: 'real' }))).toBe('Your own keys') | |
| 22 | + }) | |
| 23 | + | |
| 24 | + it('explains a demo server started with the demo command', () => { | |
| 25 | + expect(connectionDetail(status({ mode: 'demo', demoLocked: true }))).toContain('npm run demo') | |
| 26 | + expect(connectionDetail(status({ mode: 'demo' }))).not.toContain('npm run demo') | |
| 27 | + }) | |
| 28 | + | |
| 29 | + it('shows only key hints when real providers are in use', () => { | |
| 30 | + const detail = connectionDetail( | |
| 31 | + status({ mode: 'real', anthropicKeyHint: '...abcd', openaiKeyHint: '...wxyz' }), | |
| 32 | + ) | |
| 33 | + expect(detail).toContain('...abcd') | |
| 34 | + expect(detail).toContain('...wxyz') | |
| 35 | + }) | |
| 36 | +}) |
modified client/src/labels.ts +21 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | -import type { CategoryId } from 'shared/types' | |
| 1 | +import type { CategoryId, ConfigStatus } from 'shared/types' | |
| 2 | 2 | |
| 3 | 3 | export interface CategoryLabel { |
| 4 | 4 | label: string |
| @@ -16,3 +16,23 @@export const CATEGORY_LABELS: Record<CategoryId, CategoryLabel> = { | ||
| 16 | 16 | 'non-goals': { label: "What's NOT included", description: "Things you're deliberately leaving out for now" }, |
| 17 | 17 | verification: { label: "How you'll know it's done", description: 'What "finished" looks like' }, |
| 18 | 18 | } |
| 19 | + | |
| 20 | +export function connectionLabel(status: ConfigStatus): string { | |
| 21 | + if (status.mode === 'demo') return 'Demo mode' | |
| 22 | + if (status.mode === 'real') return 'Your own keys' | |
| 23 | + return 'Setup needed' | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function connectionDetail(status: ConfigStatus): string { | |
| 27 | + if (status.mode === 'demo') { | |
| 28 | + return status.demoLocked | |
| 29 | + ? 'Started with npm run demo, so this session always uses the built-in sample answers.' | |
| 30 | + : 'Using built-in sample answers. Nothing leaves this computer.' | |
| 31 | + } | |
| 32 | + if (status.mode === 'real') { | |
| 33 | + const anthropic = status.anthropicKeyHint ?? 'saved' | |
| 34 | + const openai = status.openaiKeyHint ?? 'saved' | |
| 35 | + return `Interview key ${anthropic}, speech key ${openai}. Both stay in a local file on this computer.` | |
| 36 | + } | |
| 37 | + return 'Add your two keys to talk to a real interviewer, or try the demo first.' | |
| 38 | +} |
modified scripts/setup.mjs +4 −1
| @@ -26,7 +26,10 @@async function ask(question) { | ||
| 26 | 26 | return line.trim() |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | -console.log('VoiceTask setup. Writes a local .env file (gitignored, never committed).\n') | |
| 29 | +console.log( | |
| 30 | + 'VoiceTask setup. Writes a local .env file (gitignored, never committed).\n' + | |
| 31 | + 'You can also skip this and set everything up in the browser after "npm run dev".\n', | |
| 32 | +) | |
| 30 | 33 | |
| 31 | 34 | if (existsSync('.env')) { |
| 32 | 35 | const answer = await ask('.env already exists. Overwrite it? [y/N] ') |
modified server/app.ts +16 −3
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import cors from '@fastify/cors' |
| 2 | 2 | import multipart from '@fastify/multipart' |
| 3 | 3 | import Fastify, { type FastifyInstance } from 'fastify' |
| 4 | -import { createInterviewLlm, createSttProvider } from './providers/factory' | |
| 4 | +import { createLazyInterviewLlm, createLazySttProvider } from './providers/factory' | |
| 5 | 5 | import type { InterviewLlm, SttProvider } from './providers/types' |
| 6 | 6 | import { registerAudioRoutes } from './routes/audio' |
| 7 | 7 | import { registerBlockerRoutes } from './routes/blockers' |
| 8 | +import { registerConfigRoutes } from './routes/config' | |
| 8 | 9 | import { registerExportRoutes } from './routes/export' |
| 9 | 10 | import { registerFsRoutes } from './routes/fs' |
| 10 | 11 | import { registerGenerateRoutes } from './routes/generate' |
| @@ -15,13 +16,20 @@export interface AppDeps { | ||
| 15 | 16 | store: SessionStore |
| 16 | 17 | llm: InterviewLlm |
| 17 | 18 | stt: SttProvider |
| 19 | + demoLocked: boolean | |
| 20 | + // Overridable so tests never write the real .env of this repository. | |
| 21 | + envPath?: string | |
| 22 | + env?: NodeJS.ProcessEnv | |
| 18 | 23 | } |
| 19 | 24 | |
| 20 | 25 | export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { |
| 21 | 26 | const resolved: AppDeps = { |
| 22 | 27 | store: deps.store ?? createDefaultSessionStore(), |
| 23 | - llm: deps.llm ?? createInterviewLlm(), | |
| 24 | - stt: deps.stt ?? createSttProvider(), | |
| 28 | + llm: deps.llm ?? createLazyInterviewLlm(), | |
| 29 | + stt: deps.stt ?? createLazySttProvider(), | |
| 30 | + demoLocked: deps.demoLocked ?? false, | |
| 31 | + envPath: deps.envPath, | |
| 32 | + env: deps.env, | |
| 25 | 33 | } |
| 26 | 34 | |
| 27 | 35 | const app = Fastify({ logger: false }) |
| @@ -35,6 +43,11 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { | ||
| 35 | 43 | registerBlockerRoutes(app, resolved) |
| 36 | 44 | registerFsRoutes(app) |
| 37 | 45 | registerExportRoutes(app, resolved) |
| 46 | + registerConfigRoutes(app, { | |
| 47 | + demoLocked: resolved.demoLocked, | |
| 48 | + envPath: resolved.envPath, | |
| 49 | + env: resolved.env, | |
| 50 | + }) | |
| 38 | 51 | |
| 39 | 52 | return app |
| 40 | 53 | } |
added server/config/runtimeConfig.test.ts +123 −0
| @@ -0,0 +1,123 @@ | ||
| 1 | +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 5 | +import { applyConfig, configStatus, keyHint, mergeEnvFile } from './runtimeConfig' | |
| 6 | + | |
| 7 | +describe('configStatus', () => { | |
| 8 | + it('reports demo mode when mocks are forced', () => { | |
| 9 | + const status = configStatus(true, { MOCK_PROVIDERS: '1' }) | |
| 10 | + expect(status.mode).toBe('demo') | |
| 11 | + expect(status.demoLocked).toBe(true) | |
| 12 | + }) | |
| 13 | + | |
| 14 | + it('reports real mode only when both keys are present', () => { | |
| 15 | + expect(configStatus(false, { ANTHROPIC_API_KEY: 'sk-ant-1234abcd' }).mode).toBe('unconfigured') | |
| 16 | + expect( | |
| 17 | + configStatus(false, { ANTHROPIC_API_KEY: 'sk-ant-1234abcd', OPENAI_API_KEY: 'sk-1234wxyz' }).mode, | |
| 18 | + ).toBe('real') | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('treats .env.example placeholders as unset', () => { | |
| 22 | + const status = configStatus(false, { | |
| 23 | + ANTHROPIC_API_KEY: '@insert-anthropic-api-key@', | |
| 24 | + OPENAI_API_KEY: '@insert-openai-api-key@', | |
| 25 | + }) | |
| 26 | + expect(status.mode).toBe('unconfigured') | |
| 27 | + expect(status.anthropicKeySet).toBe(false) | |
| 28 | + expect(status.openaiKeySet).toBe(false) | |
| 29 | + }) | |
| 30 | + | |
| 31 | + it('never exposes more than the last four characters of a key', () => { | |
| 32 | + const status = configStatus(false, { ANTHROPIC_API_KEY: 'sk-ant-secret-value-abcd' }) | |
| 33 | + expect(status.anthropicKeyHint).toBe('...abcd') | |
| 34 | + expect(JSON.stringify(status)).not.toContain('secret') | |
| 35 | + expect(keyHint(null)).toBeNull() | |
| 36 | + }) | |
| 37 | +}) | |
| 38 | + | |
| 39 | +describe('mergeEnvFile', () => { | |
| 40 | + it('replaces existing assignments and keeps comments and other keys', () => { | |
| 41 | + const merged = mergeEnvFile('# note\nMOCK_PROVIDERS=1\nPORT=4000\n', { | |
| 42 | + MOCK_PROVIDERS: '', | |
| 43 | + ANTHROPIC_API_KEY: 'sk-ant-new', | |
| 44 | + }) | |
| 45 | + expect(merged).toBe('# note\nMOCK_PROVIDERS=\nPORT=4000\nANTHROPIC_API_KEY=sk-ant-new\n') | |
| 46 | + }) | |
| 47 | + | |
| 48 | + it('drops duplicate assignments of an updated key', () => { | |
| 49 | + const merged = mergeEnvFile('OPENAI_API_KEY=old-one\nPORT=3001\nOPENAI_API_KEY=old-two\n', { | |
| 50 | + OPENAI_API_KEY: 'sk-new', | |
| 51 | + }) | |
| 52 | + expect(merged).toBe('OPENAI_API_KEY=sk-new\nPORT=3001\n') | |
| 53 | + }) | |
| 54 | + | |
| 55 | + it('writes into an empty file', () => { | |
| 56 | + expect(mergeEnvFile('', { MOCK_PROVIDERS: '1' })).toBe('MOCK_PROVIDERS=1\n') | |
| 57 | + }) | |
| 58 | +}) | |
| 59 | + | |
| 60 | +describe('applyConfig', () => { | |
| 61 | + let dir: string | |
| 62 | + let envPath: string | |
| 63 | + | |
| 64 | + beforeEach(async () => { | |
| 65 | + dir = await mkdtemp(path.join(tmpdir(), 'voicetask-env-')) | |
| 66 | + envPath = path.join(dir, '.env') | |
| 67 | + }) | |
| 68 | + | |
| 69 | + afterEach(async () => { | |
| 70 | + await rm(dir, { recursive: true, force: true }) | |
| 71 | + }) | |
| 72 | + | |
| 73 | + it('stores keys and switches the running process to real providers', async () => { | |
| 74 | + const env: NodeJS.ProcessEnv = { MOCK_PROVIDERS: '1' } | |
| 75 | + const result = applyConfig( | |
| 76 | + { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' }, | |
| 77 | + { demoLocked: false, envPath, env }, | |
| 78 | + ) | |
| 79 | + | |
| 80 | + expect(result.restartRequired).toBe(false) | |
| 81 | + expect(result.status.mode).toBe('real') | |
| 82 | + expect(env.MOCK_PROVIDERS).toBeUndefined() | |
| 83 | + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-1234abcd') | |
| 84 | + | |
| 85 | + const written = await readFile(envPath, 'utf8') | |
| 86 | + expect(written).toContain('ANTHROPIC_API_KEY=sk-ant-1234abcd') | |
| 87 | + expect(written).toContain('OPENAI_API_KEY=sk-1234wxyz') | |
| 88 | + expect(written).toContain('MOCK_PROVIDERS=') | |
| 89 | + }) | |
| 90 | + | |
| 91 | + it('keeps an existing key when only the other one is sent', async () => { | |
| 92 | + await writeFile(envPath, 'ANTHROPIC_API_KEY=sk-ant-old\n', 'utf8') | |
| 93 | + const env: NodeJS.ProcessEnv = { ANTHROPIC_API_KEY: 'sk-ant-old' } | |
| 94 | + applyConfig({ mode: 'real', openaiKey: 'sk-1234wxyz' }, { demoLocked: false, envPath, env }) | |
| 95 | + | |
| 96 | + const written = await readFile(envPath, 'utf8') | |
| 97 | + expect(written).toContain('ANTHROPIC_API_KEY=sk-ant-old') | |
| 98 | + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-old') | |
| 99 | + }) | |
| 100 | + | |
| 101 | + it('saves keys but stays in mocks when the server was started in demo mode', async () => { | |
| 102 | + const env: NodeJS.ProcessEnv = { MOCK_PROVIDERS: '1' } | |
| 103 | + const result = applyConfig( | |
| 104 | + { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' }, | |
| 105 | + { demoLocked: true, envPath, env }, | |
| 106 | + ) | |
| 107 | + | |
| 108 | + expect(result.restartRequired).toBe(true) | |
| 109 | + expect(result.status.mode).toBe('demo') | |
| 110 | + expect(env.ANTHROPIC_API_KEY).toBeUndefined() | |
| 111 | + expect(await readFile(envPath, 'utf8')).toContain('ANTHROPIC_API_KEY=sk-ant-1234abcd') | |
| 112 | + }) | |
| 113 | + | |
| 114 | + it('switches to demo mode without touching stored keys', async () => { | |
| 115 | + await writeFile(envPath, 'ANTHROPIC_API_KEY=sk-ant-old\nOPENAI_API_KEY=sk-old\n', 'utf8') | |
| 116 | + const env: NodeJS.ProcessEnv = { ANTHROPIC_API_KEY: 'sk-ant-old', OPENAI_API_KEY: 'sk-old' } | |
| 117 | + const result = applyConfig({ mode: 'demo' }, { demoLocked: false, envPath, env }) | |
| 118 | + | |
| 119 | + expect(result.status.mode).toBe('demo') | |
| 120 | + expect(env.MOCK_PROVIDERS).toBe('1') | |
| 121 | + expect(await readFile(envPath, 'utf8')).toContain('ANTHROPIC_API_KEY=sk-ant-old') | |
| 122 | + }) | |
| 123 | +}) |
added server/config/runtimeConfig.ts +104 −0
| @@ -0,0 +1,104 @@ | ||
| 1 | +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs' | |
| 2 | +import type { ConfigStatus, ConfigUpdateRequest } from '../../shared/types' | |
| 3 | + | |
| 4 | +export const ENV_FILE = '.env' | |
| 5 | + | |
| 6 | +const PLACEHOLDER_PREFIX = '@insert' | |
| 7 | + | |
| 8 | +export function readEnvKey(name: string, env: NodeJS.ProcessEnv = process.env): string | null { | |
| 9 | + const value = env[name]?.trim() | |
| 10 | + if (!value || value.startsWith(PLACEHOLDER_PREFIX)) return null | |
| 11 | + return value | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function keyHint(value: string | null): string | null { | |
| 15 | + if (!value) return null | |
| 16 | + return value.length <= 4 ? '****' : `...${value.slice(-4)}` | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function configStatus(demoLocked: boolean, env: NodeJS.ProcessEnv = process.env): ConfigStatus { | |
| 20 | + const anthropic = readEnvKey('ANTHROPIC_API_KEY', env) | |
| 21 | + const openai = readEnvKey('OPENAI_API_KEY', env) | |
| 22 | + const demo = env.MOCK_PROVIDERS === '1' | |
| 23 | + | |
| 24 | + return { | |
| 25 | + mode: demo ? 'demo' : anthropic && openai ? 'real' : 'unconfigured', | |
| 26 | + demoLocked, | |
| 27 | + anthropicKeySet: anthropic !== null, | |
| 28 | + openaiKeySet: openai !== null, | |
| 29 | + anthropicKeyHint: keyHint(anthropic), | |
| 30 | + openaiKeyHint: keyHint(openai), | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function mergeEnvFile(existing: string, updates: Record<string, string>): string { | |
| 35 | + const written = new Set<string>() | |
| 36 | + const lines: string[] = [] | |
| 37 | + | |
| 38 | + for (const line of existing.length > 0 ? existing.split(/\r?\n/) : []) { | |
| 39 | + const name = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] | |
| 40 | + if (name === undefined || !(name in updates)) { | |
| 41 | + lines.push(line) | |
| 42 | + continue | |
| 43 | + } | |
| 44 | + // A later duplicate would win when the file is read back, so keep only one. | |
| 45 | + if (written.has(name)) continue | |
| 46 | + written.add(name) | |
| 47 | + lines.push(`${name}=${updates[name]}`) | |
| 48 | + } | |
| 49 | + | |
| 50 | + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() | |
| 51 | + | |
| 52 | + for (const [name, value] of Object.entries(updates)) { | |
| 53 | + if (!written.has(name)) lines.push(`${name}=${value}`) | |
| 54 | + } | |
| 55 | + | |
| 56 | + return `${lines.join('\n')}\n` | |
| 57 | +} | |
| 58 | + | |
| 59 | +export interface ApplyConfigOptions { | |
| 60 | + demoLocked: boolean | |
| 61 | + envPath?: string | |
| 62 | + env?: NodeJS.ProcessEnv | |
| 63 | +} | |
| 64 | + | |
| 65 | +export interface ApplyConfigResult { | |
| 66 | + status: ConfigStatus | |
| 67 | + restartRequired: boolean | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * Persists the chosen provider mode to the local .env and, unless this process | |
| 72 | + * was started in demo mode, applies it to the running server right away. | |
| 73 | + */ | |
| 74 | +export function applyConfig(update: ConfigUpdateRequest, options: ApplyConfigOptions): ApplyConfigResult { | |
| 75 | + const env = options.env ?? process.env | |
| 76 | + const envPath = options.envPath ?? ENV_FILE | |
| 77 | + const updates: Record<string, string> = {} | |
| 78 | + | |
| 79 | + if (update.mode === 'demo') { | |
| 80 | + updates.MOCK_PROVIDERS = '1' | |
| 81 | + } else { | |
| 82 | + updates.MOCK_PROVIDERS = '' | |
| 83 | + if (update.anthropicKey) updates.ANTHROPIC_API_KEY = update.anthropicKey | |
| 84 | + if (update.openaiKey) updates.OPENAI_API_KEY = update.openaiKey | |
| 85 | + } | |
| 86 | + | |
| 87 | + const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '' | |
| 88 | + writeFileSync(envPath, mergeEnvFile(existing, updates), 'utf8') | |
| 89 | + try { | |
| 90 | + chmodSync(envPath, 0o600) | |
| 91 | + } catch { | |
| 92 | + // Permission bits are advisory here; Windows filesystems ignore them. | |
| 93 | + } | |
| 94 | + | |
| 95 | + const applyToProcess = !(options.demoLocked && update.mode === 'real') | |
| 96 | + if (applyToProcess) { | |
| 97 | + for (const [name, value] of Object.entries(updates)) { | |
| 98 | + if (value === '') delete env[name] | |
| 99 | + else env[name] = value | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + return { status: configStatus(options.demoLocked, env), restartRequired: !applyToProcess } | |
| 104 | +} |
modified server/index.ts +11 −18
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { existsSync, readFileSync } from 'node:fs' |
| 2 | 2 | import { buildApp } from './app' |
| 3 | +import { configStatus } from './config/runtimeConfig' | |
| 3 | 4 | import { applyDemoMode } from './runtimeMode' |
| 4 | 5 | |
| 5 | 6 | // .env wins over inherited shell values, so a stale MOCK_PROVIDERS=1 in some |
| @@ -14,29 +15,21 @@if (existsSync('.env')) { | ||
| 14 | 15 | } |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | -applyDemoMode(process.argv, process.env) | |
| 18 | - | |
| 19 | -if (process.env.MOCK_PROVIDERS !== '1') { | |
| 20 | - const missing = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'].filter((key) => { | |
| 21 | - const value = process.env[key] | |
| 22 | - return !value || value.startsWith('@insert') | |
| 23 | - }) | |
| 24 | - if (missing.length > 0) { | |
| 25 | - console.error( | |
| 26 | - `Missing ${missing.join(' and ')}. Run "npm run setup", or edit .env and replace the ` + | |
| 27 | - '@insert-...@ placeholders with real keys. For the offline demo set MOCK_PROVIDERS=1 (no keys needed).', | |
| 28 | - ) | |
| 29 | - process.exit(1) | |
| 30 | - } | |
| 31 | -} | |
| 18 | +const demoLocked = applyDemoMode(process.argv, process.env) | |
| 32 | 19 | |
| 33 | 20 | const PORT = Number(process.env.PORT ?? 3001) |
| 34 | 21 | |
| 22 | +function startupMode(): string { | |
| 23 | + const status = configStatus(demoLocked) | |
| 24 | + if (status.mode === 'demo') return 'MOCK providers (offline demo)' | |
| 25 | + if (status.mode === 'real') return 'real providers' | |
| 26 | + return 'no provider keys yet: add them on the VoiceTask home screen, or pick the offline demo there' | |
| 27 | +} | |
| 28 | + | |
| 35 | 29 | async function main() { |
| 36 | - const app = buildApp() | |
| 30 | + const app = buildApp({ demoLocked }) | |
| 37 | 31 | await app.listen({ port: PORT, host: '0.0.0.0' }) |
| 38 | - const mode = process.env.MOCK_PROVIDERS === '1' ? 'MOCK providers (offline demo)' : 'real providers' | |
| 39 | - console.log(`VoiceTask server listening on :${PORT} using ${mode}`) | |
| 32 | + console.log(`VoiceTask server listening on :${PORT} using ${startupMode()}`) | |
| 40 | 33 | } |
| 41 | 34 | |
| 42 | 35 | main().catch((err) => { |
modified server/providers/factory.ts +15 −0
| @@ -17,3 +17,18 @@export function createInterviewLlm(): InterviewLlm { | ||
| 17 | 17 | if (isMockMode()) return createLlmMock() |
| 18 | 18 | return createLlmAnthropic() |
| 19 | 19 | } |
| 20 | + | |
| 21 | +// Resolved per call so keys saved from the setup screen take effect without a | |
| 22 | +// server restart. Construction is local work only; no provider is contacted. | |
| 23 | +export function createLazySttProvider(): SttProvider { | |
| 24 | + return { | |
| 25 | + transcribe: (audio, mimeType) => createSttProvider().transcribe(audio, mimeType), | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function createLazyInterviewLlm(): InterviewLlm { | |
| 30 | + return { | |
| 31 | + nextTurn: (context) => createInterviewLlm().nextTurn(context), | |
| 32 | + generateFile: (request) => createInterviewLlm().generateFile(request), | |
| 33 | + } | |
| 34 | +} |
modified server/providers/llmAnthropic.ts +4 −3
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import Anthropic from '@anthropic-ai/sdk' |
| 2 | 2 | import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod' |
| 3 | 3 | import { CATEGORY_IDS, InterviewTurnSchema, type InterviewTurn } from '../../shared/types' |
| 4 | +import { readEnvKey } from '../config/runtimeConfig' | |
| 4 | 5 | import { weakestCategory } from '../engine/coverage' |
| 5 | -import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types' | |
| 6 | +import { ProviderNotConfiguredError, type GenerateFileRequest, type InterviewContext, type InterviewLlm } from './types' | |
| 6 | 7 | |
| 7 | 8 | const DEFAULT_MODEL = 'claude-opus-4-8' |
| 8 | 9 | const INTERVIEW_MAX_TOKENS = 16000 |
| @@ -37,8 +38,8 @@export function buildInterviewUserMessage(context: InterviewContext): string { | ||
| 37 | 38 | } |
| 38 | 39 | |
| 39 | 40 | export function createLlmAnthropic(): InterviewLlm { |
| 40 | - const apiKey = process.env.ANTHROPIC_API_KEY | |
| 41 | - if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set') | |
| 41 | + const apiKey = readEnvKey('ANTHROPIC_API_KEY') | |
| 42 | + if (!apiKey) throw new ProviderNotConfiguredError('Anthropic interview') | |
| 42 | 43 | const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL |
| 43 | 44 | const client = new Anthropic({ apiKey }) |
| 44 | 45 |
modified server/providers/sttOpenai.ts +4 −3
| @@ -1,4 +1,5 @@ | ||
| 1 | -import { EmptyTranscriptError, type SttProvider } from './types' | |
| 1 | +import { readEnvKey } from '../config/runtimeConfig' | |
| 2 | +import { EmptyTranscriptError, ProviderNotConfiguredError, type SttProvider } from './types' | |
| 2 | 3 | |
| 3 | 4 | const DEFAULT_STT_MODEL = 'gpt-4o-mini-transcribe' |
| 4 | 5 | |
| @@ -12,8 +13,8 @@function audioFileName(mimeType: string): string { | ||
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | export function createSttOpenai(): SttProvider { |
| 15 | - const apiKey = process.env.OPENAI_API_KEY | |
| 16 | - if (!apiKey) throw new Error('OPENAI_API_KEY is not set') | |
| 16 | + const apiKey = readEnvKey('OPENAI_API_KEY') | |
| 17 | + if (!apiKey) throw new ProviderNotConfiguredError('OpenAI speech-to-text') | |
| 17 | 18 | const model = process.env.STT_MODEL ?? DEFAULT_STT_MODEL |
| 18 | 19 | |
| 19 | 20 | return { |
modified server/providers/types.ts +6 −0
| @@ -6,6 +6,12 @@export class EmptyTranscriptError extends Error { | ||
| 6 | 6 | } |
| 7 | 7 | } |
| 8 | 8 | |
| 9 | +export class ProviderNotConfiguredError extends Error { | |
| 10 | + constructor(what: string) { | |
| 11 | + super(`No ${what} key yet. Open the VoiceTask home screen and add your keys, or switch to the offline demo.`) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 9 | 15 | export interface SttProvider { |
| 10 | 16 | transcribe(audio: Buffer, mimeType: string): Promise<string> |
| 11 | 17 | } |
modified server/routes/audio.ts +12 −1
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | import type { FastifyInstance } from 'fastify' |
| 2 | 2 | import { SessionNotFoundError, submitAnswer } from '../engine/turn' |
| 3 | -import { EmptyTranscriptError, type InterviewLlm, type SttProvider } from '../providers/types' | |
| 3 | +import { | |
| 4 | + EmptyTranscriptError, | |
| 5 | + ProviderNotConfiguredError, | |
| 6 | + type InterviewLlm, | |
| 7 | + type SttProvider, | |
| 8 | +} from '../providers/types' | |
| 4 | 9 | import type { SessionStore } from '../store/sessionStore' |
| 5 | 10 | |
| 6 | 11 | export interface AudioRouteDeps { |
| @@ -28,6 +33,9 @@export function registerAudioRoutes(app: FastifyInstance, deps: AudioRouteDeps): | ||
| 28 | 33 | if (err instanceof EmptyTranscriptError) { |
| 29 | 34 | return reply.code(400).send({ error: 'empty transcript' }) |
| 30 | 35 | } |
| 36 | + if (err instanceof ProviderNotConfiguredError) { | |
| 37 | + return reply.code(503).send({ error: err.message }) | |
| 38 | + } | |
| 31 | 39 | return reply.code(502).send({ error: err instanceof Error ? err.message : 'STT provider error' }) |
| 32 | 40 | } |
| 33 | 41 | |
| @@ -38,6 +46,9 @@export function registerAudioRoutes(app: FastifyInstance, deps: AudioRouteDeps): | ||
| 38 | 46 | if (err instanceof SessionNotFoundError) { |
| 39 | 47 | return reply.code(404).send({ error: 'session not found' }) |
| 40 | 48 | } |
| 49 | + if (err instanceof ProviderNotConfiguredError) { | |
| 50 | + return reply.code(503).send({ error: err.message }) | |
| 51 | + } | |
| 41 | 52 | throw err |
| 42 | 53 | } |
| 43 | 54 | }) |
added server/routes/config.test.ts +135 −0
| @@ -0,0 +1,135 @@ | ||
| 1 | +import { mkdtemp, readFile, rm } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { FastifyInstance } from 'fastify' | |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 6 | +import type { ConfigStatus, ConfigUpdateResponse } from '../../shared/types' | |
| 7 | +import { buildApp } from '../app' | |
| 8 | +import { createLlmMock } from '../providers/llmMock' | |
| 9 | +import { createSttMock } from '../providers/sttMock' | |
| 10 | +import { SessionStore } from '../store/sessionStore' | |
| 11 | +import { isTrustedOrigin } from './config' | |
| 12 | + | |
| 13 | +describe('config routes', () => { | |
| 14 | + let storeDir: string | |
| 15 | + let envDir: string | |
| 16 | + let envPath: string | |
| 17 | + let env: NodeJS.ProcessEnv | |
| 18 | + let app: FastifyInstance | |
| 19 | + | |
| 20 | + function build(demoLocked = false): FastifyInstance { | |
| 21 | + return buildApp({ | |
| 22 | + store: new SessionStore(storeDir), | |
| 23 | + llm: createLlmMock(), | |
| 24 | + stt: createSttMock(), | |
| 25 | + demoLocked, | |
| 26 | + envPath, | |
| 27 | + env, | |
| 28 | + }) | |
| 29 | + } | |
| 30 | + | |
| 31 | + beforeEach(async () => { | |
| 32 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 33 | + envDir = await mkdtemp(path.join(tmpdir(), 'voicetask-cfg-')) | |
| 34 | + envPath = path.join(envDir, '.env') | |
| 35 | + env = {} | |
| 36 | + app = build() | |
| 37 | + }) | |
| 38 | + | |
| 39 | + afterEach(async () => { | |
| 40 | + await app.close() | |
| 41 | + await rm(storeDir, { recursive: true, force: true }) | |
| 42 | + await rm(envDir, { recursive: true, force: true }) | |
| 43 | + }) | |
| 44 | + | |
| 45 | + it('reports an unconfigured server without leaking key values', async () => { | |
| 46 | + env.ANTHROPIC_API_KEY = 'sk-ant-value-abcd' | |
| 47 | + const res = await app.inject({ method: 'GET', url: '/api/config' }) | |
| 48 | + expect(res.statusCode).toBe(200) | |
| 49 | + const body = res.json<ConfigStatus>() | |
| 50 | + expect(body.mode).toBe('unconfigured') | |
| 51 | + expect(body.anthropicKeySet).toBe(true) | |
| 52 | + expect(body.openaiKeySet).toBe(false) | |
| 53 | + expect(res.payload).not.toContain('sk-ant-value-abcd') | |
| 54 | + expect(body.anthropicKeyHint).toBe('...abcd') | |
| 55 | + }) | |
| 56 | + | |
| 57 | + it('saves both keys and switches the server to real providers', async () => { | |
| 58 | + const res = await app.inject({ | |
| 59 | + method: 'POST', | |
| 60 | + url: '/api/config', | |
| 61 | + payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' }, | |
| 62 | + }) | |
| 63 | + expect(res.statusCode).toBe(200) | |
| 64 | + const body = res.json<ConfigUpdateResponse>() | |
| 65 | + expect(body.status.mode).toBe('real') | |
| 66 | + expect(body.restartRequired).toBe(false) | |
| 67 | + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-1234abcd') | |
| 68 | + expect(await readFile(envPath, 'utf8')).toContain('OPENAI_API_KEY=sk-1234wxyz') | |
| 69 | + }) | |
| 70 | + | |
| 71 | + it('switches to the offline demo without any key', async () => { | |
| 72 | + const res = await app.inject({ method: 'POST', url: '/api/config', payload: { mode: 'demo' } }) | |
| 73 | + expect(res.statusCode).toBe(200) | |
| 74 | + expect(res.json<ConfigUpdateResponse>().status.mode).toBe('demo') | |
| 75 | + expect(env.MOCK_PROVIDERS).toBe('1') | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('rejects real mode when a key is still missing', async () => { | |
| 79 | + const res = await app.inject({ | |
| 80 | + method: 'POST', | |
| 81 | + url: '/api/config', | |
| 82 | + payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd' }, | |
| 83 | + }) | |
| 84 | + expect(res.statusCode).toBe(400) | |
| 85 | + expect(res.json<{ error: string }>().error).toContain('OpenAI') | |
| 86 | + expect(env.ANTHROPIC_API_KEY).toBeUndefined() | |
| 87 | + }) | |
| 88 | + | |
| 89 | + it('rejects a key that contains spaces', async () => { | |
| 90 | + const res = await app.inject({ | |
| 91 | + method: 'POST', | |
| 92 | + url: '/api/config', | |
| 93 | + payload: { mode: 'real', anthropicKey: 'sk-ant abcd', openaiKey: 'sk-1234wxyz' }, | |
| 94 | + }) | |
| 95 | + expect(res.statusCode).toBe(400) | |
| 96 | + }) | |
| 97 | + | |
| 98 | + it('keeps a demo-started server on mocks and asks for a restart', async () => { | |
| 99 | + await app.close() | |
| 100 | + app = build(true) | |
| 101 | + env.MOCK_PROVIDERS = '1' | |
| 102 | + | |
| 103 | + const res = await app.inject({ | |
| 104 | + method: 'POST', | |
| 105 | + url: '/api/config', | |
| 106 | + payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' }, | |
| 107 | + }) | |
| 108 | + expect(res.statusCode).toBe(200) | |
| 109 | + const body = res.json<ConfigUpdateResponse>() | |
| 110 | + expect(body.restartRequired).toBe(true) | |
| 111 | + expect(body.status.mode).toBe('demo') | |
| 112 | + expect(env.ANTHROPIC_API_KEY).toBeUndefined() | |
| 113 | + }) | |
| 114 | + | |
| 115 | + it('refuses writes from another origin', async () => { | |
| 116 | + const res = await app.inject({ | |
| 117 | + method: 'POST', | |
| 118 | + url: '/api/config', | |
| 119 | + headers: { origin: 'https://example.com', host: 'localhost:3001' }, | |
| 120 | + payload: { mode: 'demo' }, | |
| 121 | + }) | |
| 122 | + expect(res.statusCode).toBe(403) | |
| 123 | + expect(env.MOCK_PROVIDERS).toBeUndefined() | |
| 124 | + }) | |
| 125 | + | |
| 126 | + it('accepts local and same-host origins only', () => { | |
| 127 | + expect(isTrustedOrigin(undefined, 'localhost:3001')).toBe(true) | |
| 128 | + expect(isTrustedOrigin('http://localhost:5173', 'localhost:3001')).toBe(true) | |
| 129 | + expect(isTrustedOrigin('http://127.0.0.1:3001', 'localhost:3001')).toBe(true) | |
| 130 | + expect(isTrustedOrigin('http://192.168.1.4:3001', '192.168.1.4:3001')).toBe(true) | |
| 131 | + expect(isTrustedOrigin('http://192.168.1.4:3001', 'localhost:3001')).toBe(false) | |
| 132 | + expect(isTrustedOrigin('https://example.com', 'localhost:3001')).toBe(false) | |
| 133 | + expect(isTrustedOrigin('not-a-url', 'localhost:3001')).toBe(false) | |
| 134 | + }) | |
| 135 | +}) |
added server/routes/config.ts +62 −0
| @@ -0,0 +1,62 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify' | |
| 2 | +import { ConfigUpdateRequestSchema, type ConfigUpdateResponse } from '../../shared/types' | |
| 3 | +import { applyConfig, configStatus, readEnvKey } from '../config/runtimeConfig' | |
| 4 | + | |
| 5 | +export interface ConfigRouteDeps { | |
| 6 | + demoLocked: boolean | |
| 7 | + envPath?: string | |
| 8 | + env?: NodeJS.ProcessEnv | |
| 9 | +} | |
| 10 | + | |
| 11 | +// Keys are writable from the app, so only the local UI may post here. A page on | |
| 12 | +// another origin gets a browser-supplied Origin that never matches. | |
| 13 | +export function isTrustedOrigin(origin: string | undefined, host: string | undefined): boolean { | |
| 14 | + if (!origin) return true | |
| 15 | + let url: URL | |
| 16 | + try { | |
| 17 | + url = new URL(origin) | |
| 18 | + } catch { | |
| 19 | + return false | |
| 20 | + } | |
| 21 | + const hostname = url.hostname.replace(/^\[|\]$/g, '') | |
| 22 | + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true | |
| 23 | + return host !== undefined && url.host === host | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function registerConfigRoutes(app: FastifyInstance, deps: ConfigRouteDeps): void { | |
| 27 | + const env = deps.env ?? process.env | |
| 28 | + | |
| 29 | + app.get('/api/config', async () => configStatus(deps.demoLocked, env)) | |
| 30 | + | |
| 31 | + app.post('/api/config', async (request, reply) => { | |
| 32 | + if (!isTrustedOrigin(request.headers.origin, request.headers.host)) { | |
| 33 | + return reply.code(403).send({ error: 'settings can only be changed from VoiceTask on this computer' }) | |
| 34 | + } | |
| 35 | + | |
| 36 | + const parsed = ConfigUpdateRequestSchema.safeParse(request.body) | |
| 37 | + if (!parsed.success) { | |
| 38 | + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'invalid settings' }) | |
| 39 | + } | |
| 40 | + | |
| 41 | + if (parsed.data.mode === 'real') { | |
| 42 | + const anthropic = parsed.data.anthropicKey ?? readEnvKey('ANTHROPIC_API_KEY', env) | |
| 43 | + const openai = parsed.data.openaiKey ?? readEnvKey('OPENAI_API_KEY', env) | |
| 44 | + const missing: string[] = [] | |
| 45 | + if (!anthropic) missing.push('an Anthropic key for the interview') | |
| 46 | + if (!openai) missing.push('an OpenAI key for speech-to-text') | |
| 47 | + if (missing.length > 0) { | |
| 48 | + return reply.code(400).send({ error: `Still need ${missing.join(' and ')}.` }) | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + let result: ConfigUpdateResponse | |
| 53 | + try { | |
| 54 | + result = applyConfig(parsed.data, { demoLocked: deps.demoLocked, envPath: deps.envPath, env }) | |
| 55 | + } catch (err) { | |
| 56 | + const detail = err instanceof Error ? err.message : 'unknown error' | |
| 57 | + return reply.code(500).send({ error: `Could not save the settings file: ${detail}` }) | |
| 58 | + } | |
| 59 | + | |
| 60 | + return reply.send(result) | |
| 61 | + }) | |
| 62 | +} |
modified server/routes/generate.ts +4 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { FastifyInstance } from 'fastify' |
| 2 | 2 | import { GenerateRequestSchema } from '../../shared/types' |
| 3 | 3 | import { GenerateFilesExistError, generateSpecPack } from '../generator/generate' |
| 4 | -import type { InterviewLlm } from '../providers/types' | |
| 4 | +import { ProviderNotConfiguredError, type InterviewLlm } from '../providers/types' | |
| 5 | 5 | import type { SessionStore } from '../store/sessionStore' |
| 6 | 6 | |
| 7 | 7 | export interface GenerateRouteDeps { |
| @@ -28,6 +28,9 @@export function registerGenerateRoutes(app: FastifyInstance, deps: GenerateRoute | ||
| 28 | 28 | if (err instanceof GenerateFilesExistError) { |
| 29 | 29 | return reply.code(409).send({ error: err.message }) |
| 30 | 30 | } |
| 31 | + if (err instanceof ProviderNotConfiguredError) { | |
| 32 | + return reply.code(503).send({ error: err.message }) | |
| 33 | + } | |
| 31 | 34 | return reply.code(400).send({ error: err instanceof Error ? err.message : 'generation failed' }) |
| 32 | 35 | } |
| 33 | 36 | }) |
modified server/routes/sessions.ts +4 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { FastifyInstance } from 'fastify' |
| 2 | 2 | import { AnswerRequestSchema, CreateSessionRequestSchema } from '../../shared/types' |
| 3 | 3 | import { SessionNotFoundError, submitAnswer } from '../engine/turn' |
| 4 | -import type { InterviewLlm } from '../providers/types' | |
| 4 | +import { ProviderNotConfiguredError, type InterviewLlm } from '../providers/types' | |
| 5 | 5 | import type { SessionStore } from '../store/sessionStore' |
| 6 | 6 | |
| 7 | 7 | export interface SessionRouteDeps { |
| @@ -50,6 +50,9 @@export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDe | ||
| 50 | 50 | if (err instanceof SessionNotFoundError) { |
| 51 | 51 | return reply.code(404).send({ error: 'session not found' }) |
| 52 | 52 | } |
| 53 | + if (err instanceof ProviderNotConfiguredError) { | |
| 54 | + return reply.code(503).send({ error: err.message }) | |
| 55 | + } | |
| 53 | 56 | throw err |
| 54 | 57 | } |
| 55 | 58 | }) |
modified shared/types.ts +35 −0
| @@ -118,6 +118,41 @@export const ErrorResponseSchema = z.object({ | ||
| 118 | 118 | }) |
| 119 | 119 | export type ErrorResponse = z.infer<typeof ErrorResponseSchema> |
| 120 | 120 | |
| 121 | +export const ProviderModeSchema = z.enum(['demo', 'real', 'unconfigured']) | |
| 122 | +export type ProviderMode = z.infer<typeof ProviderModeSchema> | |
| 123 | + | |
| 124 | +export const ConfigStatusSchema = z.object({ | |
| 125 | + mode: ProviderModeSchema, | |
| 126 | + demoLocked: z.boolean(), | |
| 127 | + anthropicKeySet: z.boolean(), | |
| 128 | + openaiKeySet: z.boolean(), | |
| 129 | + anthropicKeyHint: z.string().nullable(), | |
| 130 | + openaiKeyHint: z.string().nullable(), | |
| 131 | +}) | |
| 132 | +export type ConfigStatus = z.infer<typeof ConfigStatusSchema> | |
| 133 | + | |
| 134 | +const ApiKeySchema = z | |
| 135 | + .string() | |
| 136 | + .trim() | |
| 137 | + .min(8, 'that key looks too short') | |
| 138 | + .regex(/^\S+$/, 'a key cannot contain spaces') | |
| 139 | + | |
| 140 | +export const ConfigUpdateRequestSchema = z.discriminatedUnion('mode', [ | |
| 141 | + z.object({ mode: z.literal('demo') }), | |
| 142 | + z.object({ | |
| 143 | + mode: z.literal('real'), | |
| 144 | + anthropicKey: ApiKeySchema.optional(), | |
| 145 | + openaiKey: ApiKeySchema.optional(), | |
| 146 | + }), | |
| 147 | +]) | |
| 148 | +export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema> | |
| 149 | + | |
| 150 | +export const ConfigUpdateResponseSchema = z.object({ | |
| 151 | + status: ConfigStatusSchema, | |
| 152 | + restartRequired: z.boolean(), | |
| 153 | +}) | |
| 154 | +export type ConfigUpdateResponse = z.infer<typeof ConfigUpdateResponseSchema> | |
| 155 | + | |
| 121 | 156 | export const FsDirectoryEntrySchema = z.object({ |
| 122 | 157 | name: z.string(), |
| 123 | 158 | path: z.string(), |
modified spec/PLAN.md +7 −4
| @@ -8,7 +8,8 @@ | ||
| 8 | 8 | - LLM: `@anthropic-ai/sdk`. STT: OpenAI REST API via `fetch` inside the provider only (no OpenAI SDK dependency). |
| 9 | 9 | - Validation/schemas: `zod` (shared between API validation and LLM structured output). |
| 10 | 10 | - Tests: `vitest`. Typecheck: `tsc --noEmit`. |
| 11 | -- Local demo: `npm run demo` passes an explicit demo flag to the server. The flag is applied after `.env` is read, so it always forces deterministic mock providers. | |
| 11 | +- Local demo: `npm run demo` passes an explicit demo flag to the server. The flag is applied after `.env` is read, so it always forces deterministic mock providers, and it locks that process on mocks even if keys are saved from the setup screen while it runs. | |
| 12 | +- Provider setup: keys can be entered in the app. Providers are resolved per request instead of once at boot, so a saved key takes effect without a restart, and a server with no key still starts and serves the setup screen. | |
| 12 | 13 | |
| 13 | 14 | No other runtime dependencies without a BLOCKED.md entry. |
| 14 | 15 | |
| @@ -20,11 +21,13 @@shared/ types + zod schemas (Session, Segment, Coverage, API payloads) | ||
| 20 | 21 | the transcript download route and the client's copy-to-clipboard button |
| 21 | 22 | server/ |
| 22 | 23 | index.ts Fastify bootstrap, serves client build in prod |
| 23 | - routes/ sessions.ts, audio.ts, generate.ts, blockers.ts, fs.ts, export.ts | |
| 24 | + routes/ sessions.ts, audio.ts, generate.ts, blockers.ts, fs.ts, export.ts, config.ts | |
| 25 | + config/ | |
| 26 | + runtimeConfig.ts provider mode status, .env merge/write, live switch of process env | |
| 24 | 27 | store/ sessionStore.ts (JSON files under data/sessions/<id>/session.json) |
| 25 | 28 | providers/ |
| 26 | 29 | types.ts SttProvider, InterviewLlm interfaces |
| 27 | - factory.ts env-based selection, MOCK_PROVIDERS=1 forces mocks | |
| 30 | + factory.ts env-based selection, MOCK_PROVIDERS=1 forces mocks, lazy per-request wrappers | |
| 28 | 31 | sttOpenai.ts sttMock.ts |
| 29 | 32 | llmAnthropic.ts llmMock.ts |
| 30 | 33 | engine/ |
| @@ -47,7 +50,7 @@client/ | ||
| 47 | 50 | src/tts.ts speechSynthesis wrapper with browser-locale voice selection |
| 48 | 51 | src/labels.ts plain-language copy for coverage category ids |
| 49 | 52 | src/components/ Transcript, CoveragePanel, QuestionCard, GeneratePanel, FolderBrowser, |
| 50 | - TranscriptExport, OutcomePreview | |
| 53 | + TranscriptExport, OutcomePreview, ProviderSetup | |
| 51 | 54 | ``` |
| 52 | 55 | |
| 53 | 56 | ## Product experience direction |
modified spec/SPEC.md +11 −1
| @@ -25,6 +25,7 @@P1 (must have): | ||
| 25 | 25 | - US-12: As a user, I can click once to start recording and click again to send, while still having the Space key as a hold-to-talk shortcut. |
| 26 | 26 | - US-13: As a user, progress is explained in plain words and does not depend on color alone. |
| 27 | 27 | - US-14: As a user, when my build brief is ready, I can download it, copy a ready-to-send handoff message, and understand who to send it to. |
| 28 | +- US-17: As a first-time user, I can choose between my own API keys and the offline demo inside the app, without editing a file, running a setup script, or restarting the server. | |
| 28 | 29 | |
| 29 | 30 | P2 (should have): |
| 30 | 31 | - US-5: As a user, I hear the interviewer's question spoken aloud so I can keep my eyes off the screen. |
| @@ -78,7 +79,14 @@Blocker loop: | ||
| 78 | 79 | |
| 79 | 80 | Modes and safety: |
| 80 | 81 | - FR-017: With `MOCK_PROVIDERS=1`, both STT and the LLM are replaced by deterministic mocks and the full flow (record or type, interview, generate) works offline with no API keys. |
| 81 | -- FR-018: Provider API keys are read from environment variables only and never appear in logs, session files, or generated output. | |
| 82 | +- FR-018: Provider API keys are read from environment variables only, whether they were set by hand, by `npm run setup`, or by the in-app setup screen, and never appear in logs, session files, or generated output. | |
| 83 | + | |
| 84 | +Setup and provider keys: | |
| 85 | +- FR-031: The server starts even when no provider key is set. It never exits because keys are missing; it reports the unconfigured state and serves the app so setup can finish in the browser. | |
| 86 | +- FR-032: `GET /api/config` reports the provider mode (`demo`, `real`, or `unconfigured`), whether each key is set, and at most the last four characters of a saved key. Key values are never sent to the client. | |
| 87 | +- FR-033: `POST /api/config` accepts either the offline demo or one or both provider keys, writes them to the local `.env` while preserving the rest of that file, and applies them to the running server without a restart. It is accepted only from the local app origin. When the server was started with `npm run demo`, keys are saved for the next start, the running process stays on mocks, and the response reports that a restart is needed. | |
| 88 | +- FR-034: The home screen shows the setup step in place of the create form until a mode is chosen, and shows the current mode in the header with a control to change it at any time. | |
| 89 | +- FR-035: A request that needs a provider key that is not set fails with a plain-language message pointing at the setup screen, not a generic server error. | |
| 82 | 90 | |
| 83 | 91 | ## Edge cases |
| 84 | 92 | |
| @@ -95,6 +103,8 @@Modes and safety: | ||
| 95 | 103 | - Clipboard access denied: keep the download actions available and show a clear copy failure message. |
| 96 | 104 | - A completed session opened after a refresh: detect an existing spec pack and show the ready state without regenerating it. |
| 97 | 105 | - Browser speech voice list is empty on the first question: speak with the browser default and the browser language instead of failing. |
| 106 | +- Only one of the two keys is submitted from the setup screen: the missing one is named in the error and nothing is written unless the other one is already saved. | |
| 107 | +- The local `.env` cannot be written (permissions, read-only checkout): the setup screen shows the write error and the running mode is unchanged. | |
| 98 | 108 | |
| 99 | 109 | ## Out of scope (v1) |
| 100 | 110 |
modified spec/TASKS.md +5 −0
| @@ -106,3 +106,8 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 106 | 106 | - Distinguish audio sending from typed-answer waiting in microphone copy. Select speech voices by browser locale and default status instead of hardcoding English. Announce new questions, expose focus on the read-aloud switch, and contain and restore focus in the folder dialog. |
| 107 | 107 | - Depends: T20 |
| 108 | 108 | - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Voice-selection tests cover locale match, default fallback, and an empty voice list. |
| 109 | + | |
| 110 | +- [x] T22 In-app provider setup | |
| 111 | + - Start the server without keys instead of exiting. Add `GET /api/config` and `POST /api/config` (`server/routes/config.ts`, `server/config/runtimeConfig.ts`): report the provider mode and key hints, save the offline demo or one or both keys into the local `.env` without losing the rest of that file, and apply them to the running process. Resolve providers per request so a saved key works without a restart. A server started with `npm run demo` stores keys but stays on mocks and reports that a restart is needed. Accept writes only from the local app origin. Add `client/src/components/ProviderSetup.tsx`, show it in place of the create form until a mode is chosen, and show the current mode in the home header. Turn a missing key into a plain-language 503 instead of a generic server error. | |
| 112 | + - Depends: T21 | |
| 113 | + - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Tests cover the .env merge, demo-locked saving, key hints that never contain the key, the missing-key error, and origin rejection. Manual: with no `.env`, `npm run dev` starts, the browser asks for setup, and saving keys starts an interview without restarting the server. |
modified spec/VERIFICATION.md +2 −1
| @@ -21,6 +21,7 @@This must complete typechecking, all tests, and the production build with exit c | ||
| 21 | 21 | |
| 22 | 22 | ## Human smoke check |
| 23 | 23 | |
| 24 | +0. With no `.env` present, run `npm run dev` and confirm the server starts, the home screen asks for setup, and choosing the demo starts an interview without a restart. | |
| 24 | 25 | 1. Run `npm run demo` and open the printed URL in current Chrome or Edge. |
| 25 | 26 | 2. Confirm the home screen explains the three-part flow, local storage, and the source-backed result before the form. |
| 26 | 27 | 3. Create a session with a temporary target folder. |
| @@ -35,7 +36,7 @@This must complete typechecking, all tests, and the production build with exit c | ||
| 35 | 36 | |
| 36 | 37 | ## Optional real-provider check |
| 37 | 38 | |
| 38 | -With real keys selected by `npm run setup`, run `npm run dev`. Record one sentence and confirm it is transcribed, then confirm the next question is relevant. This check can call paid provider APIs and is not part of automated verification. | |
| 39 | +With real keys entered on the setup screen or selected by `npm run setup`, run `npm run dev`. Record one sentence and confirm it is transcribed, then confirm the next question is relevant. This check can call paid provider APIs and is not part of automated verification. | |
| 39 | 40 | |
| 40 | 41 | ## Per-task verification |
| 41 | 42 |