From a46fb8dfa024feabcc63b05335af2a07dc6683e0 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 29 Jun 2026 21:53:10 -0400 Subject: [PATCH] feat(web): implement VoiceView (Wave B) on cocotte ui + getVoiceAlignment Co-Authored-By: Claude Opus 4.8 --- web/src/views/VoiceView.tsx | 356 ++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 web/src/views/VoiceView.tsx diff --git a/web/src/views/VoiceView.tsx b/web/src/views/VoiceView.tsx new file mode 100644 index 0000000..445b0b1 --- /dev/null +++ b/web/src/views/VoiceView.tsx @@ -0,0 +1,356 @@ +import { useState } from 'react'; + +import { getVoiceAlignment } from '../api'; +import { usePoll, formatTime } from '../usePoll'; +import { Card, VStack, HStack, Button, Pill, Muted, Title } from '../ui'; + +/** Voice alignment lens (Wave B unit). + * Persona north-star + colored metrics row + togglable voice-corpus exemplars (sourced from Marketor) + * + before/after alignment correction pairs + this-week voice/tone fixes list. + * Backed exclusively by real GET /voice/alignment (corrections category 'voice'|'tone'). + * Renders using *only* cocotte ui primitives (Card/VStack/HStack/Button/Pill/Muted/Title). + * Client-local toggle state for exemplars (matches prototype behavior). + */ + +interface PairView { + readonly cat: string; + readonly before: string; + readonly after: string; + readonly note: string; +} + +interface CorrView { + readonly cat: string; + readonly summary: string; + readonly at: string; +} + +interface Exemplar { + readonly key: string; + readonly name: string; + readonly meta: string; + readonly source: string; + readonly synced: string; + readonly kind: string; + readonly body: string; + open: boolean; + readonly chev: string; + readonly toggle: () => void; +} + +const EXEMPLARS_BASE: ReadonlyArray> = [ + { + key: 'ts4rent', + name: 'TS4Rent · Profile', + kind: 'source of truth', + source: 'Marketor', + synced: '2d ago', + meta: 'profile body + 22 interview answers', + body: 'Pink-haired trans gamer girl, Williamsburg. Sweet sub good-girl energy, brainy-bimbo. Loves concrete nerd detail (mech keyboards, JRPGs). Incall only, $1000/hr, 2hr min for new friends. The 22 interview answers cover boundaries, kinks, vibe, and scheduling — the richest single voice source.', + }, + { + key: 'tryst', + name: 'Tryst · Bio', + kind: 'public bio', + source: 'Marketor', + synced: '2d ago', + meta: 'short public bio', + body: 'your favorite pink-haired distraction 💗 sweet, nerdy, a little bratty. incall in Williamsburg. let’s play 🌹', + }, + { + key: 'eros', + name: 'Eros · AD block', + kind: 'ad copy', + source: 'Marketor', + synced: '5d ago', + meta: 'ad-platform block', + body: 'TS Quinn · Williamsburg incall · pink hair · GFE + nerd talk · $1000/hr · verified, screening required.', + }, +]; + +function catTone(cat: string): 'accent' | 'neutral' | 'success' | 'warning' | 'error' { + if (cat === 'voice' || cat === 'tone') return 'accent'; + if (cat === 'safety') return 'error'; + if (cat === 'logistics') return 'warning'; + if (cat === 'classification') return 'success'; + return 'neutral'; +} + +export function VoiceView(): JSX.Element { + const { data, error, loading } = usePoll(() => getVoiceAlignment(50), 60000); + const [openDocs, setOpenDocs] = useState>({}); + const [realignMsg, setRealignMsg] = useState(null); + + const toggleDoc = (key: string): void => { + setOpenDocs((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const handleRealign = (): void => { + setRealignMsg('Voice re-alignment queued on GPU — folds this week’s voice/tone corrections into the drafter.'); + // Poll continues; full action would POST to a distill endpoint (not part of landed serial read). + window.setTimeout(() => setRealignMsg(null), 4200); + }; + + if (error) { + return ( + + + Voice alignment + Error loading alignment: {error} + + + ); + } + + if (loading || !data) { + return ( + + + Voice alignment + Loading voice alignment… + + + ); + } + + // Real data from /corrections/voice/alignment (loose shape in api surface; narrow safely here) + const persona: string = (data as any).persona ?? 'Direct · warm · specific rates · 🌹 signoff · bilingual ok'; + const metrics: Array<{ v: string; k: string; sub: string; color: string }> = (data as any).metrics ?? []; + const rawPairs: any[] = (data as any).pairs ?? []; + const rawCorrections: any[] = (data as any).corrections ?? []; + const corrCount: number = (data as any).corrCount ?? 0; + + const rules = persona + .split(' · ') + .filter(Boolean) + .map((r) => ({ r: r.trim() })); + + const pairs: PairView[] = rawPairs.slice(0, 4).map((r: unknown) => { + const p = r as { cat?: string; orig?: string | null; corr?: string | null }; + return { + cat: p.cat ?? 'voice', + before: p.orig ?? '', + after: p.corr ?? '', + note: '', + }; + }); + + const corrs: CorrView[] = rawCorrections.map((r: unknown) => { + const c = r as { cat?: string; summary?: string | null; at?: string }; + return { + cat: c.cat ?? 'voice', + summary: c.summary ?? '', + at: c.at ?? '', + }; + }); + + const exemplars: Exemplar[] = EXEMPLARS_BASE.map((d) => ({ + ...d, + open: !!openDocs[d.key], + chev: openDocs[d.key] ? '✕' : 'read', + toggle: () => toggleDoc(d.key), + })); + + return ( + + {/* header */} + + + Voice alignment + how close drafts sound to Quinn + + + + + {realignMsg ? {realignMsg} : null} + + {/* metrics row */} + + {metrics.length === 0 ? ( + + No metrics + + ) : ( + metrics.map((m, idx) => ( + + + + {m.v} + + {m.k} + {m.sub} + + + )) + )} + + + {/* persona card */} + + + PERSONA · NORTH STAR + + {persona} + + {rules.map((r, i) => ( + + {r.r} + + ))} + + + + {/* exemplars list (toggle) */} + + + Voice corpus · exemplars + the source of truth drafts are conditioned on · owned by Marketor + + + {exemplars.map((d) => ( + + + + {d.open ? ( + + {d.body} + + ) : null} + + + ))} + + + + {/* before/after pairs */} + + + Alignment corrections · before → after + how the teach-loop pulls drafts back to voice + + + {pairs.length === 0 ? ( + No alignment pairs yet. + ) : ( + pairs.map((p, i) => ( + + + {p.cat} + {p.note ? {p.note} : null} + + + + {p.before || '(no original)'} + + + + {p.after || '(no corrected)'} + + + + )) + )} + + + + {/* corrections list */} + + + This week’s voice & tone fixes + {corrCount} feeding the next distill + + + {corrs.length === 0 ? ( + No voice/tone corrections this period. + ) : ( + corrs.map((c, i) => ( + + {c.cat} + + {formatTime(c.at)} + + + {c.summary || '(no summary)'} + + + )) + )} + + + + ); +}