feat(web): implement VoiceView (Wave B) on cocotte ui + getVoiceAlignment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f6e72bf41f
commit
a46fb8dfa0
1 changed files with 356 additions and 0 deletions
356
web/src/views/VoiceView.tsx
Normal file
356
web/src/views/VoiceView.tsx
Normal file
|
|
@ -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<Omit<Exemplar, 'open' | 'chev' | 'toggle'>> = [
|
||||
{
|
||||
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<Record<string, boolean>>({});
|
||||
const [realignMsg, setRealignMsg] = useState<string | null>(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 (
|
||||
<Card>
|
||||
<VStack $gap={8}>
|
||||
<Title>Voice alignment</Title>
|
||||
<Muted>Error loading alignment: {error}</Muted>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack $gap={8}>
|
||||
<Title>Voice alignment</Title>
|
||||
<Muted>Loading voice alignment…</Muted>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<VStack $gap={14}>
|
||||
{/* header */}
|
||||
<HStack $justify="space-between" $align="center" $wrap $gap={10}>
|
||||
<HStack $gap={8} $align="baseline">
|
||||
<Title>Voice alignment</Title>
|
||||
<Muted>how close drafts sound to Quinn</Muted>
|
||||
</HStack>
|
||||
<Button $variant="primary" onClick={handleRealign}>
|
||||
⟳ Re-align voice
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{realignMsg ? <Muted>{realignMsg}</Muted> : null}
|
||||
|
||||
{/* metrics row */}
|
||||
<HStack $gap={8} $wrap>
|
||||
{metrics.length === 0 ? (
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Muted>No metrics</Muted>
|
||||
</Card>
|
||||
) : (
|
||||
metrics.map((m, idx) => (
|
||||
<Card key={idx} style={{ flex: 1, minWidth: 140 }}>
|
||||
<VStack $gap={3}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'ui-monospace, Menlo, monospace',
|
||||
color: m.color,
|
||||
}}
|
||||
>
|
||||
{m.v}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>{m.k}</span>
|
||||
<Muted style={{ fontSize: 10 }}>{m.sub}</Muted>
|
||||
</VStack>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* persona card */}
|
||||
<Card>
|
||||
<Muted
|
||||
style={{
|
||||
fontSize: '9.5px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
display: 'block',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
PERSONA · NORTH STAR
|
||||
</Muted>
|
||||
<span style={{ fontSize: 13, lineHeight: 1.55, marginBottom: 11, display: 'block' }}>{persona}</span>
|
||||
<HStack $gap={6} $wrap>
|
||||
{rules.map((r, i) => (
|
||||
<Pill key={i} $tone="accent">
|
||||
{r.r}
|
||||
</Pill>
|
||||
))}
|
||||
</HStack>
|
||||
</Card>
|
||||
|
||||
{/* exemplars list (toggle) */}
|
||||
<VStack $gap={4}>
|
||||
<HStack $align="baseline" $gap={9}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Voice corpus · exemplars</span>
|
||||
<Muted>the source of truth drafts are conditioned on · owned by Marketor</Muted>
|
||||
</HStack>
|
||||
<VStack $gap={9}>
|
||||
{exemplars.map((d) => (
|
||||
<Card key={d.key}>
|
||||
<VStack $gap={0}>
|
||||
<Button
|
||||
$variant="ghost"
|
||||
onClick={d.toggle}
|
||||
style={{ width: '100%', justifyContent: 'flex-start', padding: 0, textAlign: 'left' }}
|
||||
>
|
||||
<HStack $gap={11} $align="center" style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{d.name}</div>
|
||||
<Muted style={{ fontSize: 11, marginTop: 2, display: 'block' }}>
|
||||
{d.meta} · {d.source} · synced {d.synced}
|
||||
</Muted>
|
||||
</span>
|
||||
<Pill $tone="neutral">{d.kind}</Pill>
|
||||
<Muted style={{ flexShrink: 0 }}>{d.chev}</Muted>
|
||||
</HStack>
|
||||
</Button>
|
||||
{d.open ? (
|
||||
<Muted
|
||||
style={{
|
||||
marginTop: 11,
|
||||
paddingTop: 11,
|
||||
borderTop: '1px solid #232328',
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.6,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{d.body}
|
||||
</Muted>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* before/after pairs */}
|
||||
<Card>
|
||||
<HStack $justify="space-between" $align="baseline" $gap={8}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Alignment corrections · before → after</span>
|
||||
<Muted>how the teach-loop pulls drafts back to voice</Muted>
|
||||
</HStack>
|
||||
<VStack $gap={12} style={{ marginTop: 8 }}>
|
||||
{pairs.length === 0 ? (
|
||||
<Muted>No alignment pairs yet.</Muted>
|
||||
) : (
|
||||
pairs.map((p, i) => (
|
||||
<VStack $gap={6} key={i}>
|
||||
<HStack $gap={8} $align="center">
|
||||
<Pill $tone={catTone(p.cat)}>{p.cat}</Pill>
|
||||
{p.note ? <Muted>{p.note}</Muted> : null}
|
||||
</HStack>
|
||||
<HStack $gap={9} $align="stretch">
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#94959c',
|
||||
lineHeight: 1.45,
|
||||
background: '#101012',
|
||||
border: '1px solid #242428',
|
||||
borderRadius: 8,
|
||||
padding: '9px 11px',
|
||||
textDecoration: 'line-through',
|
||||
textDecorationColor: '#52525b',
|
||||
}}
|
||||
>
|
||||
{p.before || '(no original)'}
|
||||
</span>
|
||||
<span style={{ alignSelf: 'center', color: '#34d399', fontSize: 14, flexShrink: 0 }}>→</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#d6f5e8',
|
||||
lineHeight: 1.45,
|
||||
background: 'rgba(16,185,129,.1)',
|
||||
border: '1px solid rgba(16,185,129,.3)',
|
||||
borderRadius: 8,
|
||||
padding: '9px 11px',
|
||||
}}
|
||||
>
|
||||
{p.after || '(no corrected)'}
|
||||
</span>
|
||||
</HStack>
|
||||
</VStack>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* corrections list */}
|
||||
<Card>
|
||||
<HStack $justify="space-between" $align="baseline">
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>This week’s voice & tone fixes</span>
|
||||
<Muted>{corrCount} feeding the next distill</Muted>
|
||||
</HStack>
|
||||
<VStack $gap={8} style={{ marginTop: 4 }}>
|
||||
{corrs.length === 0 ? (
|
||||
<Muted>No voice/tone corrections this period.</Muted>
|
||||
) : (
|
||||
corrs.map((c, i) => (
|
||||
<HStack key={i} $gap={9} $align="center" style={{ fontSize: 12 }}>
|
||||
<Pill $tone={catTone(c.cat)}>{c.cat}</Pill>
|
||||
<Muted style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 11, flexShrink: 0 }}>
|
||||
{formatTime(c.at)}
|
||||
</Muted>
|
||||
<span
|
||||
style={{
|
||||
color: '#d4d4d8',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{c.summary || '(no summary)'}
|
||||
</span>
|
||||
</HStack>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue