Commit
T10: generate route and UI
commit
533e25d
8 changed files with +298 and −2
Jump to a changed file
- client/src/App.css +50 −0
- client/src/App.tsx +5 −1
- client/src/api.ts +9 −0
- client/src/components/GeneratePanel.tsx +112 −0
- server/app.ts +2 −0
- server/routes/generate.test.ts +85 −0
- server/routes/generate.ts +34 −0
- spec/TASKS.md +1 −1
modified client/src/App.css +50 −0
| @@ -263,6 +263,56 @@ | ||
| 263 | 263 | cursor: default; |
| 264 | 264 | } |
| 265 | 265 | |
| 266 | +.generate-panel { | |
| 267 | + margin-top: 20px; | |
| 268 | + padding: 16px 20px; | |
| 269 | + border: 1px solid var(--border); | |
| 270 | + border-radius: 10px; | |
| 271 | +} | |
| 272 | + | |
| 273 | +.generate-panel h2 { | |
| 274 | + font-size: 16px; | |
| 275 | + margin-bottom: 12px; | |
| 276 | +} | |
| 277 | + | |
| 278 | +.generate-panel button { | |
| 279 | + padding: 8px 14px; | |
| 280 | + border-radius: 6px; | |
| 281 | + border: none; | |
| 282 | + background: var(--accent); | |
| 283 | + color: white; | |
| 284 | + cursor: pointer; | |
| 285 | + margin-right: 8px; | |
| 286 | +} | |
| 287 | + | |
| 288 | +.generate-panel button:disabled { | |
| 289 | + opacity: 0.6; | |
| 290 | + cursor: default; | |
| 291 | +} | |
| 292 | + | |
| 293 | +.generate-confirm, | |
| 294 | +.generate-overwrite { | |
| 295 | + margin-bottom: 12px; | |
| 296 | +} | |
| 297 | + | |
| 298 | +.generate-confirm button:last-child, | |
| 299 | +.generate-overwrite button { | |
| 300 | + background: var(--bg-alt); | |
| 301 | + color: var(--text-h); | |
| 302 | + border: 1px solid var(--border); | |
| 303 | +} | |
| 304 | + | |
| 305 | +.generate-result ul { | |
| 306 | + margin: 8px 0 0; | |
| 307 | + padding-left: 20px; | |
| 308 | +} | |
| 309 | + | |
| 310 | +.generate-warnings { | |
| 311 | + margin-top: 8px; | |
| 312 | + color: var(--text); | |
| 313 | + opacity: 0.85; | |
| 314 | +} | |
| 315 | + | |
| 266 | 316 | .interview-done { |
| 267 | 317 | padding: 12px 16px; |
| 268 | 318 | border-radius: 8px; |
modified client/src/App.tsx +5 −1
| @@ -3,6 +3,7 @@import type { Session, SessionSummary } from 'shared/types' | ||
| 3 | 3 | import * as api from './api' |
| 4 | 4 | import './App.css' |
| 5 | 5 | import { CoveragePanel } from './components/CoveragePanel' |
| 6 | +import { GeneratePanel } from './components/GeneratePanel' | |
| 6 | 7 | import { PushToTalkButton } from './components/PushToTalkButton' |
| 7 | 8 | import { QuestionCard } from './components/QuestionCard' |
| 8 | 9 | import { Transcript } from './components/Transcript' |
| @@ -202,7 +203,10 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 202 | 203 | </form> |
| 203 | 204 | </> |
| 204 | 205 | ) : ( |
| 205 | - <p className="interview-done">Interview complete.</p> | |
| 206 | + <> | |
| 207 | + <p className="interview-done">Interview complete.</p> | |
| 208 | + <GeneratePanel sessionId={session.id} coverage={session.coverage} /> | |
| 209 | + </> | |
| 206 | 210 | )} |
| 207 | 211 | |
| 208 | 212 | {error && <p className="error">{error}</p>} |
modified client/src/api.ts +9 −0
| @@ -2,6 +2,8 @@import type { | ||
| 2 | 2 | AnswerRequest, |
| 3 | 3 | AnswerResponse, |
| 4 | 4 | CreateSessionRequest, |
| 5 | + GenerateRequest, | |
| 6 | + GenerateResponse, | |
| 5 | 7 | Session, |
| 6 | 8 | SessionSummary, |
| 7 | 9 | } from 'shared/types' |
| @@ -49,3 +51,10 @@export async function uploadAudio(id: string, audio: Blob): Promise<AnswerRespon | ||
| 49 | 51 | const res = await fetch(`${BASE}/sessions/${id}/audio`, { method: 'POST', body: form }) |
| 50 | 52 | return readJsonOrThrow<AnswerResponse>(res) |
| 51 | 53 | } |
| 54 | + | |
| 55 | +export function generateSpecPack(id: string, req: GenerateRequest = {}): Promise<GenerateResponse> { | |
| 56 | + return requestJson<GenerateResponse>(`${BASE}/sessions/${id}/generate`, { | |
| 57 | + method: 'POST', | |
| 58 | + body: JSON.stringify(req), | |
| 59 | + }) | |
| 60 | +} |
added client/src/components/GeneratePanel.tsx +112 −0
| @@ -0,0 +1,112 @@ | ||
| 1 | +import { useState } from 'react' | |
| 2 | +import { CATEGORY_IDS, type CategoryId, type Coverage, type GenerateResponse } from 'shared/types' | |
| 3 | +import * as api from '../api' | |
| 4 | + | |
| 5 | +interface GeneratePanelProps { | |
| 6 | + sessionId: string | |
| 7 | + coverage: Coverage | |
| 8 | +} | |
| 9 | + | |
| 10 | +const LABELS: Record<CategoryId, string> = { | |
| 11 | + goal: 'Goal', | |
| 12 | + users: 'Users', | |
| 13 | + 'core-flow': 'Core flow', | |
| 14 | + data: 'Data', | |
| 15 | + integrations: 'Integrations', | |
| 16 | + 'edge-cases': 'Edge cases', | |
| 17 | + constraints: 'Constraints', | |
| 18 | + 'non-goals': 'Non-goals', | |
| 19 | + verification: 'Verification', | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) { | |
| 23 | + const [confirming, setConfirming] = useState(false) | |
| 24 | + const [generating, setGenerating] = useState(false) | |
| 25 | + const [result, setResult] = useState<GenerateResponse | null>(null) | |
| 26 | + const [error, setError] = useState<string | null>(null) | |
| 27 | + const [needsOverwrite, setNeedsOverwrite] = useState(false) | |
| 28 | + | |
| 29 | + const missingCategories = CATEGORY_IDS.filter((category) => coverage[category] === 'missing') | |
| 30 | + | |
| 31 | + async function runGenerate(overwrite: boolean) { | |
| 32 | + setGenerating(true) | |
| 33 | + setError(null) | |
| 34 | + setConfirming(false) | |
| 35 | + try { | |
| 36 | + const response = await api.generateSpecPack(sessionId, { overwrite }) | |
| 37 | + setResult(response) | |
| 38 | + setNeedsOverwrite(false) | |
| 39 | + } catch (err) { | |
| 40 | + const message = err instanceof Error ? err.message : String(err) | |
| 41 | + setError(message) | |
| 42 | + setNeedsOverwrite(message.includes('already exist')) | |
| 43 | + } finally { | |
| 44 | + setGenerating(false) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + function handleGenerateClick() { | |
| 49 | + if (missingCategories.length > 0) { | |
| 50 | + setConfirming(true) | |
| 51 | + return | |
| 52 | + } | |
| 53 | + void runGenerate(false) | |
| 54 | + } | |
| 55 | + | |
| 56 | + return ( | |
| 57 | + <div className="generate-panel"> | |
| 58 | + <h2>Generate spec pack</h2> | |
| 59 | + | |
| 60 | + {confirming ? ( | |
| 61 | + <div className="generate-confirm"> | |
| 62 | + <p> | |
| 63 | + These categories are still missing: {missingCategories.map((c) => LABELS[c]).join(', ')}. Generate | |
| 64 | + anyway? | |
| 65 | + </p> | |
| 66 | + <button type="button" onClick={() => void runGenerate(false)} disabled={generating}> | |
| 67 | + Generate anyway | |
| 68 | + </button> | |
| 69 | + <button type="button" onClick={() => setConfirming(false)} disabled={generating}> | |
| 70 | + Cancel | |
| 71 | + </button> | |
| 72 | + </div> | |
| 73 | + ) : ( | |
| 74 | + <button type="button" onClick={handleGenerateClick} disabled={generating}> | |
| 75 | + {generating ? 'Generating…' : 'Generate spec pack'} | |
| 76 | + </button> | |
| 77 | + )} | |
| 78 | + | |
| 79 | + {needsOverwrite && ( | |
| 80 | + <div className="generate-overwrite"> | |
| 81 | + <p>A spec pack already exists in the target directory.</p> | |
| 82 | + <button type="button" onClick={() => void runGenerate(true)} disabled={generating}> | |
| 83 | + Overwrite and regenerate | |
| 84 | + </button> | |
| 85 | + </div> | |
| 86 | + )} | |
| 87 | + | |
| 88 | + {error && <p className="error">{error}</p>} | |
| 89 | + | |
| 90 | + {result && ( | |
| 91 | + <div className="generate-result"> | |
| 92 | + <p>Wrote {result.files.length} files:</p> | |
| 93 | + <ul> | |
| 94 | + {result.files.map((file) => ( | |
| 95 | + <li key={file}>{file}</li> | |
| 96 | + ))} | |
| 97 | + </ul> | |
| 98 | + {result.warnings.length > 0 && ( | |
| 99 | + <div className="generate-warnings"> | |
| 100 | + <p>Warnings:</p> | |
| 101 | + <ul> | |
| 102 | + {result.warnings.map((warning) => ( | |
| 103 | + <li key={warning}>{warning}</li> | |
| 104 | + ))} | |
| 105 | + </ul> | |
| 106 | + </div> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + )} | |
| 110 | + </div> | |
| 111 | + ) | |
| 112 | +} |
modified server/app.ts +2 −0
| @@ -4,6 +4,7 @@import Fastify, { type FastifyInstance } from 'fastify' | ||
| 4 | 4 | import { createInterviewLlm, createSttProvider } from './providers/factory' |
| 5 | 5 | import type { InterviewLlm, SttProvider } from './providers/types' |
| 6 | 6 | import { registerAudioRoutes } from './routes/audio' |
| 7 | +import { registerGenerateRoutes } from './routes/generate' | |
| 7 | 8 | import { registerSessionRoutes } from './routes/sessions' |
| 8 | 9 | import { createDefaultSessionStore, SessionStore } from './store/sessionStore' |
| 9 | 10 | |
| @@ -27,6 +28,7 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { | ||
| 27 | 28 | app.get('/api/health', async () => ({ ok: true })) |
| 28 | 29 | registerSessionRoutes(app, resolved) |
| 29 | 30 | registerAudioRoutes(app, resolved) |
| 31 | + registerGenerateRoutes(app, resolved) | |
| 30 | 32 | |
| 31 | 33 | return app |
| 32 | 34 | } |
added server/routes/generate.test.ts +85 −0
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { mkdtemp, readdir, 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 { GenerateResponse, Session } from '../../shared/types' | |
| 7 | +import { buildApp } from '../app' | |
| 8 | +import { createLlmMock } from '../providers/llmMock' | |
| 9 | +import { SessionStore } from '../store/sessionStore' | |
| 10 | + | |
| 11 | +describe('generate route', () => { | |
| 12 | + let storeDir: string | |
| 13 | + let targetDir: string | |
| 14 | + let app: FastifyInstance | |
| 15 | + | |
| 16 | + beforeEach(async () => { | |
| 17 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 18 | + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-')) | |
| 19 | + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() }) | |
| 20 | + }) | |
| 21 | + | |
| 22 | + afterEach(async () => { | |
| 23 | + await app.close() | |
| 24 | + await rm(storeDir, { recursive: true, force: true }) | |
| 25 | + await rm(targetDir, { recursive: true, force: true }) | |
| 26 | + }) | |
| 27 | + | |
| 28 | + async function createSessionWithAnAnswer(): Promise<Session> { | |
| 29 | + const created = await app | |
| 30 | + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir } }) | |
| 31 | + .then((r) => r.json<Session>()) | |
| 32 | + await app.inject({ | |
| 33 | + method: 'POST', | |
| 34 | + url: `/api/sessions/${created.id}/answer`, | |
| 35 | + payload: { text: 'we are building a todo app' }, | |
| 36 | + }) | |
| 37 | + return app.inject({ method: 'GET', url: `/api/sessions/${created.id}` }).then((r) => r.json<Session>()) | |
| 38 | + } | |
| 39 | + | |
| 40 | + it('generates the six spec pack files', async () => { | |
| 41 | + const session = await createSessionWithAnAnswer() | |
| 42 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 43 | + expect(res.statusCode).toBe(200) | |
| 44 | + const body = res.json<GenerateResponse>() | |
| 45 | + expect(body.files).toHaveLength(6) | |
| 46 | + expect(body.files).toContain('spec/SPEC.md') | |
| 47 | + }) | |
| 48 | + | |
| 49 | + it('returns 409 on a second generate without overwrite', async () => { | |
| 50 | + const session = await createSessionWithAnAnswer() | |
| 51 | + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 52 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 53 | + expect(res.statusCode).toBe(409) | |
| 54 | + }) | |
| 55 | + | |
| 56 | + it('regenerates and creates a backup dir when overwrite is set', async () => { | |
| 57 | + const session = await createSessionWithAnAnswer() | |
| 58 | + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 59 | + const res = await app.inject({ | |
| 60 | + method: 'POST', | |
| 61 | + url: `/api/sessions/${session.id}/generate`, | |
| 62 | + payload: { overwrite: true }, | |
| 63 | + }) | |
| 64 | + expect(res.statusCode).toBe(200) | |
| 65 | + const entries = await readdir(path.join(targetDir, 'spec')) | |
| 66 | + expect(entries.some((e) => e.startsWith('backup-'))).toBe(true) | |
| 67 | + }) | |
| 68 | + | |
| 69 | + it('returns 404 for an unknown session', async () => { | |
| 70 | + const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/generate', payload: {} }) | |
| 71 | + expect(res.statusCode).toBe(404) | |
| 72 | + }) | |
| 73 | + | |
| 74 | + it('returns 400 when the target directory does not exist', async () => { | |
| 75 | + const created = await app | |
| 76 | + .inject({ | |
| 77 | + method: 'POST', | |
| 78 | + url: '/api/sessions', | |
| 79 | + payload: { name: 'p', targetDir: path.join(targetDir, 'does-not-exist') }, | |
| 80 | + }) | |
| 81 | + .then((r) => r.json<Session>()) | |
| 82 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${created.id}/generate`, payload: {} }) | |
| 83 | + expect(res.statusCode).toBe(400) | |
| 84 | + }) | |
| 85 | +}) |
added server/routes/generate.ts +34 −0
| @@ -0,0 +1,34 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify' | |
| 2 | +import { GenerateRequestSchema } from '../../shared/types' | |
| 3 | +import { GenerateFilesExistError, generateSpecPack } from '../generator/generate' | |
| 4 | +import type { InterviewLlm } from '../providers/types' | |
| 5 | +import type { SessionStore } from '../store/sessionStore' | |
| 6 | + | |
| 7 | +export interface GenerateRouteDeps { | |
| 8 | + store: SessionStore | |
| 9 | + llm: InterviewLlm | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function registerGenerateRoutes(app: FastifyInstance, deps: GenerateRouteDeps): void { | |
| 13 | + const { store, llm } = deps | |
| 14 | + | |
| 15 | + app.post<{ Params: { id: string } }>('/api/sessions/:id/generate', async (request, reply) => { | |
| 16 | + const parsed = GenerateRequestSchema.safeParse(request.body ?? {}) | |
| 17 | + if (!parsed.success) { | |
| 18 | + return reply.code(400).send({ error: parsed.error.message }) | |
| 19 | + } | |
| 20 | + | |
| 21 | + const session = await store.getSession(request.params.id) | |
| 22 | + if (!session) return reply.code(404).send({ error: 'session not found' }) | |
| 23 | + | |
| 24 | + try { | |
| 25 | + const result = await generateSpecPack(llm, session, { overwrite: parsed.data.overwrite }) | |
| 26 | + return reply.send(result) | |
| 27 | + } catch (err) { | |
| 28 | + if (err instanceof GenerateFilesExistError) { | |
| 29 | + return reply.code(409).send({ error: err.message }) | |
| 30 | + } | |
| 31 | + return reply.code(400).send({ error: err instanceof Error ? err.message : 'generation failed' }) | |
| 32 | + } | |
| 33 | + }) | |
| 34 | +} |
modified spec/TASKS.md +1 −1
| @@ -47,7 +47,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 47 | 47 | - Depends: T5 |
| 48 | 48 | - Verify: `npm test` (with llmMock into a temp dir: all six files written; bogus `[S999]` marker removed and `[unverified]` appended; second run without overwrite fails; with overwrite creates backup dir). |
| 49 | 49 | |
| 50 | -- [ ] T10 Generate route and UI | |
| 50 | +- [x] T10 Generate route and UI | |
| 51 | 51 | - `POST /api/sessions/:id/generate`, GeneratePanel with missing-category confirmation dialog per FR-008, result/warnings display. |
| 52 | 52 | - Depends: T7, T9 |
| 53 | 53 | - Verify: `npm test` (route test) and `npm run typecheck`. |