Files
proof-of-work/proof-of-work/components/ai/GenerateWorkoutClient.tsx
T
Keysat 2b0abad68e
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
v1.2.0:6 — AI "generate today's workout" from a brain-dump
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).

Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).

Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.

Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
2026-06-19 10:59:12 -05:00

627 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Sparkles } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
import type { AiWorkoutDraft } from '@/lib/ai/workoutDraft';
interface LibraryExercise {
id: string;
name: string;
type: string;
}
// AI output shape — mirrors lib/ai/workoutSchema.ts (AIWorkout).
interface AIWorkoutExercise {
exerciseId: string | null;
exerciseName: string;
order: number;
sets?: number | null;
reps?: number | null;
suggestedWeight?: number | null;
suggestedWeightUnit?: 'lbs' | 'kg' | null;
rpe?: number | null;
gear?: number | null;
durationSeconds?: number | null;
notes?: string | null;
}
interface AIWorkout {
name: string;
notes?: string | null;
exercises: AIWorkoutExercise[];
}
// The ephemeral draft we hand to the New Workout form via sessionStorage.
export const AI_WORKOUT_DRAFT_KEY = 'ai-workout-draft';
type Phase =
| { kind: 'idle' }
| { kind: 'streaming'; raw: string; lastPartial: Partial<AIWorkout> | null }
| { kind: 'failed'; raw: string; message: string };
export default function GenerateWorkoutClient({
exercises,
providerLabel,
modelLabel,
workoutCount,
}: {
exercises: LibraryExercise[];
providerLabel: string;
modelLabel: string;
workoutCount: number;
}) {
const router = useRouter();
const [userInput, setUserInput] = useState('');
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 1);
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
// The editable suggestion once parsed. Lifted to the parent so the
// Refine action can send the user's current edits back as the prior
// workout. null until the first successful parse.
const [workout, setWorkout] = useState<AIWorkout | null>(null);
// Refine instruction lives here (not in WorkoutPreview) because the
// preview unmounts while streaming — keeping it in the parent means a
// failed refine doesn't lose what the user typed; we clear it only on
// a successful regeneration.
const [refineInput, setRefineInput] = useState('');
const [tokens, setTokens] = useState<{ in?: number; out?: number; durationMs?: number }>({});
const closeStreamRef = useRef<(() => void) | null>(null);
const streaming = phase.kind === 'streaming';
/**
* Run a generation. `priorWorkout` present → REVISION mode: `input`
* is the change instruction and the model re-emits the full workout.
*/
const runGeneration = async (input: string, priorWorkout?: AIWorkout) => {
if (!input.trim()) return;
setPhase({ kind: 'streaming', raw: '', lastPartial: null });
setTokens({});
let id: string;
try {
const res = await fetch('/api/ai/generate-workout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userInput: input,
includeHistory,
priorWorkout: priorWorkout ?? null,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setPhase({ kind: 'failed', raw: '', message: body.error ?? `HTTP ${res.status}` });
return;
}
id = body.id;
} catch (e) {
setPhase({ kind: 'failed', raw: '', message: (e as Error).message });
return;
}
attachStream(id);
};
const attachStream = (id: string) => {
const es = new EventSource(`/api/ai/generations/${id}/stream`);
closeStreamRef.current = () => es.close();
let raw = '';
let lastPartial: Partial<AIWorkout> | null = null;
es.addEventListener('text', (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
raw += data.delta;
const next = lenientJsonParse(raw) as Partial<AIWorkout> | null;
if (next) lastPartial = next; // sticky — kills flicker between parses
setPhase({ kind: 'streaming', raw, lastPartial });
});
es.addEventListener('usage', (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
setTokens((t) => ({ ...t, in: data.tokensIn, out: data.tokensOut }));
});
es.addEventListener('complete', async (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
es.close();
closeStreamRef.current = null;
setTokens((t) => ({
...t,
in: data.tokensIn ?? t.in,
out: data.tokensOut ?? t.out,
durationMs: data.durationMs,
}));
if (data.parsedOk) {
const r = await fetch(`/api/ai/generations/${id}`);
if (r.ok) {
const gen = await r.json();
if (gen.parsedProgram) {
setWorkout(JSON.parse(gen.parsedProgram) as AIWorkout);
setRefineInput(''); // consumed — clear only on success
setPhase({ kind: 'idle' });
return;
}
}
}
setPhase({
kind: 'failed',
raw,
message: data.errorMessage ?? 'Failed to parse model output.',
});
});
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) {
closeStreamRef.current = null;
setPhase((p) =>
p.kind === 'streaming'
? {
kind: 'failed',
raw: p.raw,
message:
'Stream disconnected. The generation may still be running — check AI · History.',
}
: p,
);
}
};
};
// Warn before unload while streaming (the runner keeps going server-side).
useEffect(() => {
if (!streaming) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [streaming]);
// Detach on unmount; the server keeps generating regardless.
useEffect(() => () => closeStreamRef.current?.(), []);
const costStr = useMemo(() => {
if (tokens.in == null || tokens.out == null) return null;
return formatCost(
estimateCost({
provider: providerLabel,
model: modelLabel,
tokensIn: tokens.in,
tokensOut: tokens.out,
}),
);
}, [providerLabel, modelLabel, tokens.in, tokens.out]);
const showResult = streaming || phase.kind === 'failed' || workout != null;
return (
<div className="space-y-6">
<div className="text-xs text-zinc-500 uppercase tracking-wider">
Provider: <span className="text-zinc-300">{providerLabel}</span>
{' · '}Model: <span className="text-zinc-300">{modelLabel}</span>
</div>
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<Field label="Describe today's workout">
<textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder="e.g. Upper body, focus on shoulders. Overhead press, 4 working sets. Abs: landmine rotation, oblique cable, landmine hollow-body hold. Pull-ups, biceps and triceps."
rows={6}
className={inputClass}
disabled={streaming}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={includeHistory}
onChange={(e) => setIncludeHistory(e.target.checked)}
disabled={streaming || workoutCount === 0}
className="mt-0.5"
/>
<span>
Use my history to suggest weights{' '}
<span className="text-zinc-500">
({workoutCount === 0
? 'no workouts logged yet — disabled'
: 'last 90 days · recent working weights per exercise'}
)
</span>
</span>
</label>
<button
type="button"
onClick={() => runGeneration(userInput)}
disabled={!userInput.trim() || streaming}
className="inline-flex items-center gap-2 px-5 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
>
<Sparkles className="w-4 h-4" />
{workout ? 'Regenerate' : 'Generate workout'}
</button>
</section>
{showResult && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
{streaming ? 'Generating…' : 'Suggested workout'}
</h2>
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
{tokens.in != null && (
<>
{tokens.in} in · {tokens.out ?? '?'} out
</>
)}
{costStr && <> · {costStr}</>}
{tokens.durationMs != null && (
<> · {(tokens.durationMs / 1000).toFixed(1)}s</>
)}
</span>
</div>
{streaming && (
<>
{phase.lastPartial ? (
<PartialPreview partial={phase.lastPartial} />
) : (
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Waiting for the first parseable JSON
</div>
)}
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw stream</summary>
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap mt-2">
{phase.raw || '(waiting for first token…)'}
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
</div>
</details>
</>
)}
{phase.kind === 'failed' && (
<>
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
{phase.message}
</div>
{phase.raw && (
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw response</summary>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap">
{phase.raw}
</pre>
</details>
)}
</>
)}
{!streaming && workout && (
<WorkoutPreview
workout={workout}
setWorkout={(updater) => setWorkout((w) => (w ? updater(w) : w))}
exercises={exercises}
refineInput={refineInput}
setRefineInput={setRefineInput}
onRefine={() => runGeneration(refineInput, workout)}
onUse={(draft) => {
sessionStorage.setItem(AI_WORKOUT_DRAFT_KEY, JSON.stringify(draft));
router.push('/main/workouts/new?from=ai');
}}
/>
)}
</section>
)}
</div>
);
}
function WorkoutPreview({
workout,
setWorkout,
exercises,
refineInput,
setRefineInput,
onRefine,
onUse,
}: {
workout: AIWorkout;
setWorkout: (updater: (w: AIWorkout) => AIWorkout) => void;
exercises: LibraryExercise[];
refineInput: string;
setRefineInput: (v: string) => void;
onRefine: () => void;
onUse: (draft: AiWorkoutDraft) => void;
}) {
const [error, setError] = useState<string | null>(null);
const exerciseLookup = useMemo(
() => new Map(exercises.map((e) => [e.id, e])),
[exercises],
);
const unresolvedCount = useMemo(
() =>
workout.exercises.filter(
(ex) => !ex.exerciseId || !exerciseLookup.has(ex.exerciseId),
).length,
[workout, exerciseLookup],
);
const updateExercise = (
idx: number,
patch: Partial<AIWorkoutExercise>,
) => {
setWorkout((w) => {
const next = structuredClone(w);
next.exercises[idx] = { ...next.exercises[idx], ...patch };
return next;
});
};
const removeExercise = (idx: number) => {
setWorkout((w) => {
const next = structuredClone(w);
next.exercises.splice(idx, 1);
next.exercises.forEach((ex, i) => (ex.order = i));
return next;
});
};
const numOrNull = (v: string) => {
if (v.trim() === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const handleUse = () => {
if (unresolvedCount > 0) {
setError(`Map or remove the ${unresolvedCount} unknown exercise(s) first.`);
return;
}
setError(null);
onUse({
name: workout.name,
notes: workout.notes ?? undefined,
exercises: workout.exercises.map((ex) => ({
exerciseId: ex.exerciseId!,
sets: ex.sets && ex.sets > 0 ? ex.sets : 3,
reps: ex.reps ?? undefined,
suggestedWeight: ex.suggestedWeight ?? undefined,
suggestedWeightUnit: ex.suggestedWeightUnit ?? undefined,
rpe: ex.rpe ?? undefined,
gear: ex.gear ?? undefined,
durationSeconds: ex.durationSeconds ?? undefined,
notes: ex.notes ?? undefined,
})),
});
};
return (
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<div>
<input
value={workout.name}
onChange={(e) => setWorkout((w) => ({ ...w, name: e.target.value }))}
className="text-lg font-bold text-white bg-transparent border-b border-transparent hover:border-zinc-700 focus:border-zinc-500 focus:outline-none w-full"
/>
{workout.notes && (
<p className="text-sm text-zinc-400 mt-1 italic">{workout.notes}</p>
)}
<p className="text-xs text-zinc-500 mt-1">
{workout.exercises.length} exercise
{workout.exercises.length === 1 ? '' : 's'}
</p>
</div>
{unresolvedCount > 0 && (
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
{unresolvedCount} exercise(s) the AI couldn&apos;t map to your library.
Pick a replacement or remove them before using this workout.
</div>
)}
<ul className="space-y-2">
{workout.exercises.map((ex, idx) => {
const isUnknown = !ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
const lib = ex.exerciseId ? exerciseLookup.get(ex.exerciseId) : null;
return (
<li
key={idx}
className={`rounded p-3 ${
isUnknown
? 'bg-amber-950/30 border border-amber-900'
: 'bg-zinc-950 border border-zinc-800'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="text-white text-sm">
{lib?.name ?? ex.exerciseName}
{isUnknown && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
not in library
</span>
)}
</div>
<button
type="button"
onClick={() => removeExercise(idx)}
className="text-xs text-red-400 hover:text-red-300 px-1"
title="Remove from workout"
>
</button>
</div>
{isUnknown && (
<select
value={ex.exerciseId ?? ''}
onChange={(e) =>
updateExercise(idx, { exerciseId: e.target.value || null })
}
className="mt-2 w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
>
<option value="">Map to existing exercise</option>
{exercises.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.name} ({opt.type})
</option>
))}
</select>
)}
<div className="mt-2 grid grid-cols-3 gap-2">
<NumField
label="Sets"
value={ex.sets}
onChange={(v) => updateExercise(idx, { sets: v })}
/>
<NumField
label="Reps"
value={ex.reps}
onChange={(v) => updateExercise(idx, { reps: v })}
/>
<NumField
label={`Weight${ex.suggestedWeightUnit ? ` (${ex.suggestedWeightUnit})` : ''}`}
value={ex.suggestedWeight}
step="any"
onChange={(v) => updateExercise(idx, { suggestedWeight: v })}
/>
</div>
{ex.notes && (
<div className="text-xs text-zinc-400 mt-2 italic">{ex.notes}</div>
)}
</li>
);
})}
</ul>
<div className="border-t border-zinc-800 pt-4 space-y-3">
<Field label="Refine (send a change back to the AI)">
<div className="flex gap-2">
<input
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
onKeyDown={(e) => {
// Cleared by the parent only on a successful regeneration, so
// a failed refine keeps what the user typed.
if (e.key === 'Enter' && refineInput.trim()) onRefine();
}}
placeholder="e.g. make overhead press 5 sets; swap the oblique exercise"
className={inputClass}
/>
<button
type="button"
onClick={() => {
if (refineInput.trim()) onRefine();
}}
disabled={!refineInput.trim()}
className="shrink-0 px-4 py-2 rounded bg-zinc-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-zinc-600 disabled:bg-zinc-800 disabled:text-zinc-600"
>
Refine
</button>
</div>
</Field>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
<button
type="button"
onClick={handleUse}
disabled={unresolvedCount > 0 || workout.exercises.length === 0}
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
>
Use this workout
</button>
<p className="text-[11px] text-zinc-500">
Opens a pre-filled workout nothing is saved until you save it there.
</p>
</div>
</div>
);
function NumField({
label,
value,
step,
onChange,
}: {
label: string;
value?: number | null;
step?: string;
onChange: (v: number | null) => void;
}) {
return (
<label className="block">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider block mb-0.5">
{label}
</span>
<input
type="number"
inputMode="decimal"
step={step}
value={value ?? ''}
onChange={(e) => onChange(numOrNull(e.target.value))}
className="w-full px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-800 text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</label>
);
}
}
const inputClass =
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
{label}
</span>
{children}
</label>
);
}
function PartialPreview({ partial }: { partial: Partial<AIWorkout> }) {
const exercises = (partial.exercises as AIWorkoutExercise[] | undefined) ?? [];
return (
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Loader2 className="w-3 h-3 animate-spin text-zinc-500" />
<span className="text-zinc-400">
Building workout{' '}
{partial.name && (
<span className="text-white font-semibold">{partial.name}</span>
)}
</span>
</div>
{exercises.length > 0 && (
<ul className="text-xs text-zinc-300 space-y-1">
{exercises.map((ex, i) => (
<li key={i}>
<span className="text-zinc-500">{(ex?.order ?? i) + 1}.</span>{' '}
{ex?.exerciseName ?? '…'}
{ex?.sets ? (
<span className="text-zinc-500">
{' '}
· {ex.sets}×{ex.reps ?? '?'}
{ex.suggestedWeight != null
? ` @ ${ex.suggestedWeight}${ex.suggestedWeightUnit ?? ''}`
: ''}
</span>
) : null}
</li>
))}
</ul>
)}
</div>
);
}