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.
This commit is contained in:
@@ -112,13 +112,13 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
|
||||
|
||||
## Current state
|
||||
|
||||
Latest version is **1.2.0:5** — **Gear replaces RPE as the cardio effort field**: cardio exercises now log a breathing "Gear" (1–5, Brian MacKenzie) select instead of RPE (6–10); strength keeps RPE. An exercise is cardio when its equipment `type` is "cardio" **or** its `muscleGroups` contains "cardio" (`isCardioExercise` in `lib/exerciseOptions.ts`) — so Assault Bike (type "assault bike") qualifies, as do Box jump & Soccer (both tagged cardio). New nullable `SetLog.gear` column via boot-time guarded `ALTER`; plumbed through all 5 set-write paths, summary/edit views, CSV/JSON import-export. Program/AI **target**-RPE is a separate concept and untouched. **Built + sideloaded** (`immense-voyage.local`, 2026-06-16, `master`) as `proof-of-work_x86_64.s9pk` (80M, git `4be489d`). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), **231 tests pass** (incl. gear + `isCardioExercise`), `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`).
|
||||
Latest version is **1.2.0:6** — **AI "generate today's workout"**: a new AI flow alongside program generation. Describe one session in plain words → a streamed, ready-to-log workout (exercises + suggested weights/reps/set-counts grounded in 90-day history) → inline-edit + a **Refine** box that round-trips changes back to the LLM → **Use this workout** pre-fills the normal New Workout form (nothing persists until you save). Reuses the whole generation spine (detached runner / SSE / lenient-JSON / 5 providers / `historyContext`) via a new **`AIGeneration.kind`** discriminant (`"program" | "workout"`, default "program"); the runner picks the parser by kind and stores JSON in the reused `parsedProgram` column. Workout rows are **ephemeral** (the saved Workout is the durable record) so they're filtered out of the program-shaped AI History (`kind:'program'`). Refine = a fresh generation seeded with the prior suggestion JSON (validated via `aiWorkoutSchema` → REVISION mode in `workoutPrompt.ts`). Hand-off is sessionStorage → `/main/workouts/new?from=ai` → `AiWorkoutPrefill` (`workoutDraft.ts::buildPrefillExercises`: expands to N sets, cardio→Gear / strength→RPE via `isCardioExercise`, drops unmapped ids). `EditWorkoutData.id` is now optional so the prefill **creates** (not PATCHes). AI suggests each weight in that exercise's effective unit (library JSON carries per-exercise `unit` = `defaultWeightUnit || "lbs"`, matching what `WorkoutForm.buildPayload` stores). New `AIGeneration.kind` column via boot-time guarded `ALTER`. New files: `lib/ai/workoutSchema.ts`, `workoutPrompt.ts`, `workoutDraft.ts`, `api/ai/generate-workout/route.ts`, `components/ai/GenerateWorkoutClient.tsx`, `components/workouts/AiWorkoutPrefill.tsx`, `app/main/ai/generate-workout/page.tsx`. **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), **251 tests pass** (incl. `parseAIWorkout`, `buildPrefillExercises` gear/RPE mapping, generate-workout route auth/validation), `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`). See `docs/guides/ai-subsystem.md` → "Two generation kinds".
|
||||
|
||||
**Confirmed on-box (2026-06-16, via `start-cli`):** box runs `1.2.0:5`; entrypoint logged `adding missing column SetLog.gear` and (earlier boot) `SetLog.watts`, each once; app launches `as nextjs` with no permission errors (clears the 1.2.0:3 / long-standing 1.1.0:9 non-root check). App DB shows an Assault Bike set saved with `gear=1` and no `rpe` — Gear select renders + persists for cardio, RPE for strength. Recent prior ships (1.2.0 line): **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership); **1.2.0:2** iOS Safari login first-tap retry; **1.2.0:1** Next 15 / React 19 upgrade.
|
||||
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:6`; entrypoint logged `adding AIGeneration.kind (default 'program')` once, then launched `as nextjs` with no errors (clears the long-standing non-root check); read-only `SELECT` confirms the `AIGeneration.kind` column exists and the existing generation row backfilled to `program`. Recent prior ships (1.2.0 line): **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field; **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership).
|
||||
|
||||
**No on-box checks pending.** Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced on 1.2.0:5, first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
|
||||
**No on-box checks pending.** Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
|
||||
|
||||
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
|
||||
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, **single-workout generation + refine**, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
|
||||
|
||||
Next steps (priority order):
|
||||
1. **Finish the P3 hardening batch** (`ROADMAP.md` → Security & hardening — timing oracle + exerciseId ownership now DONE): CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (`try{json}catch{→{}}`) in `programs/[id]/days/[dayId]/start`.
|
||||
|
||||
@@ -20,6 +20,29 @@ generate/generations route handlers). Whole-repo rules live in `AGENTS.md`.
|
||||
- Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId`
|
||||
points at the active one and is mirrored into the legacy `ai*` columns for back-compat.
|
||||
|
||||
## Two generation kinds (`AIGeneration.kind`)
|
||||
|
||||
The runner spine is shared by two output shapes, discriminated by `AIGeneration.kind`
|
||||
("program" | "workout", default "program"). The runner picks the parser by kind and
|
||||
stores the JSON in the (reused) `parsedProgram` column.
|
||||
|
||||
- **program** (`kind: 'program'`) — `generate/route.ts` → `programSchema.ts`
|
||||
(`PROGRAM_OUTPUT_SHAPE` / `parseAIProgram`). Applied to DB rows via `apply.ts`.
|
||||
Shown in AI · History (which filters `kind: 'program'`).
|
||||
- **workout** (`kind: 'workout'`) — `generate-workout/route.ts` (uses
|
||||
`workoutPrompt.ts` + `workoutSchema.ts`: `WORKOUT_OUTPUT_SHAPE` / `parseAIWorkout`).
|
||||
A single day's session. **No server-side apply**: the client (`GenerateWorkoutClient.tsx`)
|
||||
stashes the reviewed suggestion in `sessionStorage` and routes to
|
||||
`/main/workouts/new?from=ai`, where `AiWorkoutPrefill.tsx` expands it (via
|
||||
`workoutDraft.ts::buildPrefillExercises`) and pre-fills the normal `WorkoutForm` —
|
||||
nothing persists until the user saves through the regular workout path.
|
||||
**Refine = a new workout generation** seeded with the prior suggestion JSON
|
||||
(`priorWorkout` in the route body → REVISION mode in `workoutPrompt.ts`). These rows
|
||||
are ephemeral, so they're excluded from the program-shaped AI · History.
|
||||
- Adding a new kind: extend the union in `KickoffOpts`, add a parser + output-shape,
|
||||
branch the parser selection in `generationRunner.ts`, and decide whether it belongs in
|
||||
History (filtered by kind).
|
||||
|
||||
## Provider abstraction
|
||||
|
||||
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { WORKOUT_OUTPUT_SHAPE, aiWorkoutSchema } from '@/lib/ai/workoutSchema';
|
||||
import {
|
||||
buildHistorySummary,
|
||||
formatHistoryContext,
|
||||
} from '@/lib/ai/historyContext';
|
||||
import { buildWorkoutSystemPrompt } from '@/lib/ai/workoutPrompt';
|
||||
import { kickoffGeneration } from '@/lib/ai/generationRunner';
|
||||
|
||||
/**
|
||||
* POST /api/ai/generate-workout
|
||||
*
|
||||
* Kicks off a background runner (kind="workout") that streams a single
|
||||
* day's workout suggestion, and returns the generation id. The caller
|
||||
* subscribes via GET /api/ai/generations/[id]/stream (SSE) — the same
|
||||
* spine as program generation.
|
||||
*
|
||||
* Body:
|
||||
* { userInput: string, includeHistory?: boolean, priorWorkout?: AIWorkout }
|
||||
*
|
||||
* `priorWorkout` switches the prompt into REVISION mode: userInput is the
|
||||
* change instruction and the model re-emits the full revised workout.
|
||||
*
|
||||
* Response:
|
||||
* 201 { id: "...generationId..." }
|
||||
* 400 { error: "..." }
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
userInput: z.string().min(1),
|
||||
includeHistory: z.boolean().optional().default(false),
|
||||
// The current suggestion, when refining. Validated against the same
|
||||
// shape the model emits so we only ever feed it well-formed JSON.
|
||||
priorWorkout: aiWorkoutSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs';
|
||||
|
||||
// Library for the prompt. We include each exercise's effective logging
|
||||
// unit (`defaultWeightUnit || "lbs"` — the exact unit the saved workout
|
||||
// will store, see WorkoutForm.buildPayload) so the model suggests the
|
||||
// weight NUMBER in that unit. Without this the model would assume the
|
||||
// user's global preferred unit, which diverges for per-exercise unit
|
||||
// overrides (e.g. kettlebells in kg) and silently mislabels the weight.
|
||||
const exercises = await prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
muscleGroups: true,
|
||||
defaultWeightUnit: true,
|
||||
},
|
||||
});
|
||||
const libraryJson = JSON.stringify(
|
||||
exercises.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
unit: e.defaultWeightUnit || 'lbs',
|
||||
muscleGroups: (() => {
|
||||
try {
|
||||
return JSON.parse(e.muscleGroups);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
})),
|
||||
);
|
||||
|
||||
// History context if requested.
|
||||
let historyBlock = '';
|
||||
if (parsed.data.includeHistory) {
|
||||
const summary = await buildHistorySummary(prisma, user.id);
|
||||
historyBlock = formatHistoryContext(summary);
|
||||
}
|
||||
|
||||
const isLocalModel = prefs.aiProvider === 'ollama';
|
||||
const priorWorkoutJson = parsed.data.priorWorkout
|
||||
? JSON.stringify(parsed.data.priorWorkout)
|
||||
: undefined;
|
||||
|
||||
const basePrompt = buildWorkoutSystemPrompt({
|
||||
weightUnit,
|
||||
hasHistoryContext: parsed.data.includeHistory,
|
||||
isLocalModel,
|
||||
priorWorkoutJson,
|
||||
});
|
||||
|
||||
const systemPrompt = `${basePrompt}
|
||||
|
||||
# OUTPUT SHAPE
|
||||
|
||||
${WORKOUT_OUTPUT_SHAPE}
|
||||
|
||||
# LIBRARY (use these exerciseIds; do not invent ids)
|
||||
|
||||
${libraryJson}${historyBlock}`;
|
||||
|
||||
const id = await kickoffGeneration({
|
||||
prisma,
|
||||
userId: user.id,
|
||||
kind: 'workout',
|
||||
templateId: null,
|
||||
templateName: priorWorkoutJson ? 'Workout (refine)' : 'Workout',
|
||||
userInput: parsed.data.userInput,
|
||||
systemPrompt,
|
||||
userPrompt: parsed.data.userInput,
|
||||
provider: prefs.aiProvider,
|
||||
model: prefs.aiModel,
|
||||
apiKey: prefs.aiApiKey,
|
||||
baseUrl: prefs.aiBaseUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json({ id }, { status: 201 });
|
||||
}
|
||||
@@ -147,6 +147,7 @@ ${libraryJson}${historyBlock}`;
|
||||
const id = await kickoffGeneration({
|
||||
prisma,
|
||||
userId: user.id,
|
||||
kind: 'program',
|
||||
templateId: template?.id ?? null,
|
||||
templateName: template?.name ?? null,
|
||||
userInput: parsed.data.userInput,
|
||||
|
||||
@@ -16,7 +16,8 @@ export async function GET(request: NextRequest) {
|
||||
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
|
||||
|
||||
const rows = await prisma.aIGeneration.findMany({
|
||||
where: { userId: user.id },
|
||||
// Program history only; workout-kind rows are ephemeral (see history page).
|
||||
where: { userId: user.id, kind: 'program' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
skip: offset,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import GenerateWorkoutClient from '@/components/ai/GenerateWorkoutClient';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GenerateWorkoutPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/auth/login');
|
||||
|
||||
const [exercises, prefs, workoutCount] = await Promise.all([
|
||||
prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, name: true, type: true },
|
||||
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiProvider: true, aiModel: true },
|
||||
}),
|
||||
prisma.workout.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
<div className="border-b border-zinc-800">
|
||||
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||
<Link
|
||||
href="/main/ai"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
aria-label="Back to AI"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||
AI · Today's workout
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||
{!aiConfigured ? (
|
||||
<div className="bg-amber-950/30 border border-amber-900 rounded p-5 text-sm text-amber-200">
|
||||
<p className="font-bold text-amber-100 mb-2">AI is not configured.</p>
|
||||
<p>
|
||||
Pick a provider, model, and (if needed) API key in{' '}
|
||||
<Link href="/main/settings" className="underline hover:text-amber-100">
|
||||
Settings → AI integration
|
||||
</Link>{' '}
|
||||
before you can generate a workout.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<GenerateWorkoutClient
|
||||
exercises={exercises}
|
||||
providerLabel={prefs!.aiProvider!}
|
||||
modelLabel={prefs!.aiModel!}
|
||||
workoutCount={workoutCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,8 @@ export default async function GenerationDetailPage(props: {
|
||||
|
||||
const [row, exercises] = await Promise.all([
|
||||
prisma.aIGeneration.findFirst({
|
||||
where: { id: params.id, userId: user.id },
|
||||
// Program history only — workout-kind rows aren't shown here.
|
||||
where: { id: params.id, userId: user.id, kind: 'program' },
|
||||
}),
|
||||
prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
|
||||
@@ -12,7 +12,10 @@ export default async function HistoryPage() {
|
||||
if (!user) redirect('/auth/login');
|
||||
|
||||
const rows = await prisma.aIGeneration.findMany({
|
||||
where: { userId: user.id },
|
||||
// Program history only. Single-workout generations (kind="workout")
|
||||
// are ephemeral — the durable record is the saved Workout — so they
|
||||
// don't belong in this program-shaped list/detail.
|
||||
where: { userId: user.id, kind: 'program' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 25,
|
||||
select: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Sparkles, ListChecks, History } from 'lucide-react';
|
||||
import { Sparkles, ListChecks, History, Dumbbell } from 'lucide-react';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
@@ -17,6 +17,14 @@ export default async function AIIndexPage() {
|
||||
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
href: '/main/ai/generate-workout',
|
||||
icon: Dumbbell,
|
||||
title: "Today's workout",
|
||||
blurb:
|
||||
'Describe today\'s session in plain words and get a ready-to-log workout — exercises with suggested weights and reps from your history. Refine it, then pre-fill the log.',
|
||||
disabled: !aiConfigured,
|
||||
},
|
||||
{
|
||||
href: '/main/ai/generate',
|
||||
icon: Sparkles,
|
||||
|
||||
@@ -44,7 +44,8 @@ const navLinks: NavLink[] = [
|
||||
label: 'AI',
|
||||
icon: Sparkles,
|
||||
subItems: [
|
||||
{ href: '/main/ai/generate', label: 'Generate' },
|
||||
{ href: '/main/ai/generate-workout', label: "Today's workout" },
|
||||
{ href: '/main/ai/generate', label: 'Generate program' },
|
||||
{ href: '/main/ai/history', label: 'History' },
|
||||
{ href: '/main/ai/templates', label: 'Templates' },
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getCurrentUser } from "@/lib/auth";
|
||||
import { getExercises } from "@/lib/db/exercises";
|
||||
import { getWorkoutById } from "@/lib/db/workouts";
|
||||
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
|
||||
import AiWorkoutPrefill from "@/components/workouts/AiWorkoutPrefill";
|
||||
|
||||
export const metadata = {
|
||||
title: "Log Workout",
|
||||
@@ -12,7 +13,7 @@ export const metadata = {
|
||||
};
|
||||
|
||||
export default async function NewWorkoutPage(props: {
|
||||
searchParams: Promise<{ edit?: string }>;
|
||||
searchParams: Promise<{ edit?: string; from?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const user = await getCurrentUser();
|
||||
@@ -22,6 +23,11 @@ export default async function NewWorkoutPage(props: {
|
||||
|
||||
const exercises = await getExercises(user.id);
|
||||
|
||||
// Coming from the AI "today's workout" flow: the suggestion is in
|
||||
// sessionStorage (client-only), so a client wrapper reads it and
|
||||
// pre-fills the form. No ?edit fetch here.
|
||||
const fromAi = searchParams.from === "ai";
|
||||
|
||||
// If ?edit=WORKOUT_ID, fetch existing workout for editing
|
||||
let editWorkout: EditWorkoutData | undefined;
|
||||
if (searchParams.edit) {
|
||||
@@ -95,11 +101,15 @@ export default async function NewWorkoutPage(props: {
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 pb-12">
|
||||
{fromAi ? (
|
||||
<AiWorkoutPrefill exercises={exercises} />
|
||||
) : (
|
||||
<WorkoutForm
|
||||
exercises={exercises}
|
||||
recentlyUsedExercises={[]}
|
||||
editWorkout={editWorkout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
'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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Exercise } from '@prisma/client';
|
||||
import WorkoutForm, { EditWorkoutData } from './WorkoutForm';
|
||||
import { AI_WORKOUT_DRAFT_KEY } from '@/components/ai/GenerateWorkoutClient';
|
||||
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
|
||||
|
||||
/**
|
||||
* Reads the ephemeral AI workout draft from sessionStorage (stashed by
|
||||
* GenerateWorkoutClient before navigating to /main/workouts/new?from=ai),
|
||||
* expands each suggested exercise into N pre-filled SetLogs, and renders
|
||||
* the normal WorkoutForm. Nothing is persisted until the user saves
|
||||
* through the regular workout path.
|
||||
*
|
||||
* The draft has no workout id, so WorkoutForm's first save CREATEs.
|
||||
* Effort follows the app convention: cardio → gear (1-5), else → rpe.
|
||||
*
|
||||
* If the draft is missing (e.g. a refresh cleared it), we fall back to a
|
||||
* blank form so the page is never broken.
|
||||
*/
|
||||
export default function AiWorkoutPrefill({
|
||||
exercises,
|
||||
}: {
|
||||
exercises: Exercise[];
|
||||
}) {
|
||||
// Read + build once on mount via a lazy initializer. This stays PURE
|
||||
// (no sessionStorage mutation) so React's StrictMode double-invoke is
|
||||
// safe — both passes read the same draft. The one-shot removal happens
|
||||
// in the effect below, after the value is captured.
|
||||
const [editWorkout] = useState<EditWorkoutData | undefined>(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const raw = sessionStorage.getItem(AI_WORKOUT_DRAFT_KEY);
|
||||
if (!raw) return undefined;
|
||||
|
||||
let draft: AiWorkoutDraft;
|
||||
try {
|
||||
draft = JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const builtExercises: EditWorkoutData['exercises'] =
|
||||
buildPrefillExercises(draft, exercises);
|
||||
|
||||
if (builtExercises.length === 0) return undefined;
|
||||
|
||||
return {
|
||||
// No id → first save CREATEs a new workout.
|
||||
name: draft.name || '',
|
||||
date: new Date().toISOString(),
|
||||
notes: draft.notes,
|
||||
exercises: builtExercises,
|
||||
};
|
||||
});
|
||||
|
||||
// Clear the one-shot draft after mount so a manual reload starts blank.
|
||||
// removeItem is idempotent, so StrictMode's double-run is harmless.
|
||||
useEffect(() => {
|
||||
sessionStorage.removeItem(AI_WORKOUT_DRAFT_KEY);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WorkoutForm
|
||||
exercises={exercises}
|
||||
recentlyUsedExercises={[]}
|
||||
editWorkout={editWorkout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -245,7 +245,10 @@ interface ExerciseWithSets {
|
||||
}
|
||||
|
||||
export interface EditWorkoutData {
|
||||
id: string;
|
||||
/// Existing workout id when editing. Omitted for a pre-filled NEW
|
||||
/// workout (e.g. an AI suggestion) so `savedWorkoutId` starts null and
|
||||
/// the first save CREATEs instead of PATCHing a nonexistent id.
|
||||
id?: string;
|
||||
name: string;
|
||||
date: string; // ISO string
|
||||
durationMinutes?: number | null;
|
||||
@@ -655,9 +658,11 @@ export default function WorkoutForm({
|
||||
if (!response.ok) throw new Error("Failed to save workout");
|
||||
}
|
||||
|
||||
// Navigate back: to detail page if editing, otherwise to list
|
||||
if (editWorkout) {
|
||||
router.push(`/main/workouts/${savedWorkoutId || editWorkout.id}`);
|
||||
// Navigate back: to detail page if we have a workout id (editing, or
|
||||
// an AI-prefilled workout that has now been created), otherwise to list.
|
||||
const detailId = savedWorkoutId || editWorkout?.id;
|
||||
if (detailId) {
|
||||
router.push(`/main/workouts/${detailId}`);
|
||||
} else {
|
||||
router.push("/main/workouts");
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { getProvider } from './providers';
|
||||
import { parseAIProgram } from './programSchema';
|
||||
import { parseAIWorkout } from './workoutSchema';
|
||||
|
||||
export interface GenerationDelta {
|
||||
type: 'text' | 'usage' | 'complete' | 'error';
|
||||
@@ -114,6 +115,9 @@ export function subscribe(
|
||||
export interface KickoffOpts {
|
||||
prisma: PrismaClient;
|
||||
userId: string;
|
||||
/** "program" (multi-week) or "workout" (single day). Selects the
|
||||
* output parser and is persisted on the row. */
|
||||
kind: 'program' | 'workout';
|
||||
templateId: string | null;
|
||||
templateName: string | null;
|
||||
userInput: string;
|
||||
@@ -139,6 +143,7 @@ export async function kickoffGeneration(opts: KickoffOpts): Promise<string> {
|
||||
const generation = await opts.prisma.aIGeneration.create({
|
||||
data: {
|
||||
userId: opts.userId,
|
||||
kind: opts.kind,
|
||||
templateId: opts.templateId,
|
||||
templateName: opts.templateName,
|
||||
userInput: opts.userInput,
|
||||
@@ -248,10 +253,11 @@ async function runGeneration(generationId: string, opts: KickoffOpts) {
|
||||
let parsedJson: string | null = null;
|
||||
let parseErr: string | null = null;
|
||||
if (!providerError && raw) {
|
||||
const r = parseAIProgram(raw);
|
||||
const r =
|
||||
opts.kind === 'workout' ? parseAIWorkout(raw) : parseAIProgram(raw);
|
||||
if (r.ok) {
|
||||
parsedOk = true;
|
||||
parsedJson = JSON.stringify(r.program);
|
||||
parsedJson = JSON.stringify('workout' in r ? r.workout : r.program);
|
||||
} else {
|
||||
parseErr = r.reason;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { isCardioExercise } from '@/lib/exerciseOptions';
|
||||
|
||||
/**
|
||||
* The ephemeral draft the "today's workout" flow hands to the New Workout
|
||||
* form (via sessionStorage). One entry per exercise, with a working set
|
||||
* count plus a single target weight/reps that we expand into N identical
|
||||
* pre-filled sets. Shared by the producer (GenerateWorkoutClient) and the
|
||||
* consumer (AiWorkoutPrefill) so the shape stays in sync.
|
||||
*/
|
||||
export interface AiWorkoutDraftExercise {
|
||||
exerciseId: string;
|
||||
sets: number;
|
||||
reps?: number;
|
||||
suggestedWeight?: number;
|
||||
suggestedWeightUnit?: 'lbs' | 'kg';
|
||||
rpe?: number;
|
||||
gear?: number;
|
||||
durationSeconds?: number;
|
||||
notes?: string;
|
||||
}
|
||||
export interface AiWorkoutDraft {
|
||||
name: string;
|
||||
notes?: string;
|
||||
exercises: AiWorkoutDraftExercise[];
|
||||
}
|
||||
|
||||
export interface PrefillSet {
|
||||
setNumber: number;
|
||||
reps?: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
gear?: number;
|
||||
durationSeconds?: number;
|
||||
notes?: string;
|
||||
}
|
||||
export interface PrefillExercise<E> {
|
||||
exercise: E;
|
||||
sets: PrefillSet[];
|
||||
}
|
||||
|
||||
/** Default working sets when the model omits a positive count. */
|
||||
const DEFAULT_SET_COUNT = 3;
|
||||
|
||||
/**
|
||||
* Expand a draft into pre-filled exercises against the user's library.
|
||||
*
|
||||
* - Exercises whose `exerciseId` isn't in the library are dropped (the
|
||||
* preview forces the user to map them first, so this is just defensive).
|
||||
* - Each exercise becomes `sets` identical SetLogs seeded with the
|
||||
* suggested weight/reps.
|
||||
* - Effort follows the app convention: cardio logs `gear` (1-5), every
|
||||
* other exercise logs `rpe`. We keep only the matching one so a stray
|
||||
* value on the wrong kind never reaches the form.
|
||||
* - The coaching note rides only on the first set (avoids N copies).
|
||||
*/
|
||||
export function buildPrefillExercises<
|
||||
E extends { id: string; type?: string | null; muscleGroups?: string | null },
|
||||
>(draft: AiWorkoutDraft, exercises: E[]): PrefillExercise<E>[] {
|
||||
const byId = new Map(exercises.map((e) => [e.id, e]));
|
||||
const out: PrefillExercise<E>[] = [];
|
||||
for (const d of draft.exercises) {
|
||||
const exercise = byId.get(d.exerciseId);
|
||||
if (!exercise) continue;
|
||||
const cardio = isCardioExercise(exercise);
|
||||
const setCount = d.sets && d.sets > 0 ? d.sets : DEFAULT_SET_COUNT;
|
||||
const sets: PrefillSet[] = Array.from({ length: setCount }, (_, i) => ({
|
||||
setNumber: i + 1,
|
||||
reps: d.reps,
|
||||
weight: d.suggestedWeight,
|
||||
rpe: cardio ? undefined : d.rpe,
|
||||
gear: cardio ? d.gear : undefined,
|
||||
durationSeconds: d.durationSeconds,
|
||||
notes: i === 0 ? d.notes : undefined,
|
||||
}));
|
||||
out.push({ exercise, sets });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* System-prompt builder for the "generate today's workout" flow. The
|
||||
* sibling of systemPromptBase.ts (which targets multi-week programs).
|
||||
*
|
||||
* Job: force the single-workout JSON contract, ground suggested weights
|
||||
* in the user's history, and respect the app's Gear-vs-RPE effort
|
||||
* convention (cardio logs breathing Gear 1-5; everything else logs
|
||||
* RPE 6-10).
|
||||
*/
|
||||
|
||||
export interface WorkoutPromptOpts {
|
||||
/** "lbs" | "kg" — default suggestedWeightUnit when the model omits one. */
|
||||
weightUnit: 'lbs' | 'kg';
|
||||
/** Whether the user's workout history is included in the prompt. */
|
||||
hasHistoryContext: boolean;
|
||||
/** True when the model is local (Ollama) — needs blunter, shorter rules. */
|
||||
isLocalModel: boolean;
|
||||
/** When refining, the prior suggestion's JSON. Present → revision mode. */
|
||||
priorWorkoutJson?: string;
|
||||
}
|
||||
|
||||
export function buildWorkoutSystemPrompt(opts: WorkoutPromptOpts): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(
|
||||
'# ROLE',
|
||||
'',
|
||||
"You are a strength & conditioning coach building ONE training session for today from the user's brain-dump. Turn their loose description into a concrete, ready-to-log workout.",
|
||||
'',
|
||||
'# OUTPUT CONTRACT (mandatory)',
|
||||
'',
|
||||
'1. Reply with EXACTLY ONE JSON object matching the OUTPUT SHAPE. No prose before or after. No ```json fences.',
|
||||
'2. Every exercise must use an `exerciseId` from the LIBRARY block. NEVER invent ids. If nothing fits, pick the closest match and explain the substitution in `notes`.',
|
||||
'3. Honor what the user asked for: include the exercises they named, with the set counts / emphasis they specified. Add sensible accessory work only if they asked you to fill out a body part (e.g. "let\'s do biceps and triceps").',
|
||||
`4. Every resistance exercise MUST have a \`suggestedWeight\` (a number) and a target \`reps\`. Cardio, stretching, and bodyweight exercises set \`suggestedWeight\` to null.`,
|
||||
`5. Express \`suggestedWeight\` in THAT exercise's \`unit\` from the LIBRARY block, and set \`suggestedWeightUnit\` to match it (default "${opts.weightUnit}" if none is shown). Don't convert — give the number in the exercise's own unit.`,
|
||||
'6. `sets` is the number of working sets to pre-fill (e.g. the user\'s "4 working sets" → sets: 4).',
|
||||
'7. EFFORT: for CARDIO exercises set `gear` (1-5 breathing gear) and leave `rpe` null. For everything else set `rpe` (1-10) and leave `gear` null.',
|
||||
'8. Use `durationSeconds` instead of `reps` for timed work (holds, carries, intervals).',
|
||||
'9. `notes` is for a short coaching cue — one sentence, optional.',
|
||||
'10. Keep it to a single realistic session (typically 3-8 exercises). Do NOT invent multiple days or weeks — this is ONE workout.',
|
||||
);
|
||||
|
||||
if (opts.hasHistoryContext) {
|
||||
lines.push(
|
||||
'',
|
||||
'# USING THE HISTORY BLOCK',
|
||||
'',
|
||||
"The HISTORY block below summarizes the user's last 90 days. Use it to:",
|
||||
'- Set `suggestedWeight` near their recent working weights for that exercise, NOT round numbers from nowhere.',
|
||||
'- Nudge progressive overload where appropriate (small jump if a lift is moving; hold or deload if STAGNANT).',
|
||||
'- Match the rep ranges and effort they tend to train at.',
|
||||
"- If an exercise they named has no history, estimate conservatively and say so in `notes`.",
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
'',
|
||||
'# WEIGHT GUIDANCE WITHOUT HISTORY',
|
||||
'',
|
||||
`Without prior data, set conservative \`suggestedWeight\` values (round gym increments; 5${opts.weightUnit} jumps, 2.5${opts.weightUnit} for small accessories) and add a coaching note like "adjust to leave 2-3 reps in reserve" so the user knows it's a starting estimate.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.priorWorkoutJson) {
|
||||
lines.push(
|
||||
'',
|
||||
'# REVISION MODE',
|
||||
'',
|
||||
'The user already has the workout below and wants you to change it. Apply their requested change and re-emit the COMPLETE revised workout as one JSON object (not a diff). Keep everything they did not ask to change.',
|
||||
'',
|
||||
'CURRENT WORKOUT:',
|
||||
opts.priorWorkoutJson,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.isLocalModel) {
|
||||
lines.push(
|
||||
'',
|
||||
'# LOCAL MODEL REMINDER',
|
||||
'',
|
||||
'You are running locally with limited reasoning. Build the simplest valid single-session workout that matches the request. Do not overthink. JSON only.',
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod';
|
||||
import { extractJson } from './programSchema';
|
||||
|
||||
/**
|
||||
* The shape we ask LLMs to produce for a SINGLE day's workout (the
|
||||
* "generate today's workout" flow). Distinct from the multi-week
|
||||
* AIProgram in programSchema.ts.
|
||||
*
|
||||
* This does NOT map onto a DB table directly: the user reviews/edits the
|
||||
* suggestion, then it pre-populates the normal New Workout form (nothing
|
||||
* is persisted until they save through the regular workout path). So the
|
||||
* shape is optimized for "pre-fill a logger" not "INSERT a Program".
|
||||
*
|
||||
* Per exercise we ask for a working `sets` count plus a single target
|
||||
* `reps` / `suggestedWeight` — the hand-off expands that into N identical
|
||||
* pre-filled SetLogs. (No warmup/ramping distinction in v1.)
|
||||
*
|
||||
* `exerciseId` is nullable: the model picks from the user's library when
|
||||
* it can, but may suggest something not in the library (the preview
|
||||
* prompts the user to map it). `exerciseName` is REQUIRED as the display
|
||||
* label + fuzzy-match fallback.
|
||||
*/
|
||||
|
||||
export const aiWorkoutExerciseSchema = z.object({
|
||||
exerciseId: z.string().nullable(),
|
||||
exerciseName: z.string().min(1),
|
||||
order: z.number().int().nonnegative(),
|
||||
/// Number of working sets to pre-fill. Defaults to 3 in the hand-off
|
||||
/// if the model omits it.
|
||||
sets: z.number().int().positive().optional().nullable(),
|
||||
/// Target reps per set (the user overwrites with what they actually
|
||||
/// did). Omit for time/distance-based work.
|
||||
reps: z.number().int().positive().optional().nullable(),
|
||||
/// Suggested working weight. Null for cardio / bodyweight / stretching.
|
||||
suggestedWeight: z.number().nonnegative().optional().nullable(),
|
||||
/// "lbs" | "kg". Optional; hand-off falls back to the user's
|
||||
/// defaultWeightUnit when null.
|
||||
suggestedWeightUnit: z.enum(['lbs', 'kg']).optional().nullable(),
|
||||
/// Strength effort (1-10). The hand-off keeps this only for non-cardio
|
||||
/// exercises (cardio uses `gear`).
|
||||
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||
/// Cardio breathing gear (1-5). The hand-off keeps this only for
|
||||
/// cardio exercises (strength uses `rpe`).
|
||||
gear: z.number().int().min(1).max(5).optional().nullable(),
|
||||
/// Target duration in seconds for time-based work (e.g. a hold).
|
||||
durationSeconds: z.number().int().positive().optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const aiWorkoutSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
notes: z.string().optional().nullable(),
|
||||
exercises: z.array(aiWorkoutExerciseSchema),
|
||||
});
|
||||
|
||||
export type AIWorkout = z.infer<typeof aiWorkoutSchema>;
|
||||
export type AIWorkoutExercise = z.infer<typeof aiWorkoutExerciseSchema>;
|
||||
|
||||
/**
|
||||
* JSON-schema-ish doc pasted into the system prompt so the model knows
|
||||
* the exact shape to emit (same approach as PROGRAM_OUTPUT_SHAPE — not a
|
||||
* provider "structured output" mode, since Ollama support is uneven).
|
||||
*/
|
||||
export const WORKOUT_OUTPUT_SHAPE = `{
|
||||
"name": "<string, e.g. Upper Body — Shoulder Focus>",
|
||||
"notes": "<string, optional, one-line session summary>",
|
||||
"exercises": [
|
||||
{
|
||||
"exerciseId": "<string — REQUIRED — must be an id from the LIBRARY block. If no library exercise fits, pick the closest match and explain in notes; do NOT invent ids.>",
|
||||
"exerciseName": "<string, the canonical name from the library>",
|
||||
"order": <int >= 0>,
|
||||
"sets": <int >= 1, number of working sets>,
|
||||
"reps": <int, target reps per set; omit for time/distance work>,
|
||||
"suggestedWeight": <number, working weight in the exercise's LIBRARY \`unit\`; omit/null for cardio, bodyweight, stretching>,
|
||||
"suggestedWeightUnit": "<\\"lbs\\" | \\"kg\\", optional — match the exercise's \`unit\` from the LIBRARY>",
|
||||
"rpe": <int 1-10, strength effort; use for NON-cardio exercises>,
|
||||
"gear": <int 1-5, cardio breathing gear; use for CARDIO exercises instead of rpe>,
|
||||
"durationSeconds": <int, optional, for timed holds/intervals>,
|
||||
"notes": "<string, optional, short coaching cue>"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Parse + validate a model's raw response into an AIWorkout. Returns a
|
||||
* clean workout or a structured error. Mirrors parseAIProgram.
|
||||
*/
|
||||
export function parseAIWorkout(
|
||||
raw: string,
|
||||
):
|
||||
| { ok: true; workout: AIWorkout }
|
||||
| { ok: false; reason: string; json?: string } {
|
||||
const json = extractJson(raw);
|
||||
if (!json) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Could not find a JSON object in the response.',
|
||||
};
|
||||
}
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(json);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `JSON parse error: ${(e as Error).message}`,
|
||||
json,
|
||||
};
|
||||
}
|
||||
const result = aiWorkoutSchema.safeParse(obj);
|
||||
if (!result.success) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'JSON did not match the expected shape: ' +
|
||||
result.error.errors
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join('; '),
|
||||
json,
|
||||
};
|
||||
}
|
||||
return { ok: true, workout: result.data };
|
||||
}
|
||||
@@ -422,13 +422,20 @@ model AIGeneration {
|
||||
userInput String
|
||||
systemPrompt String
|
||||
userPrompt String
|
||||
/// What this generation produces: "program" (multi-week Program) or
|
||||
/// "workout" (a single day's workout the user pre-fills the log from).
|
||||
/// Drives which parser the runner uses and which UI consumes the row.
|
||||
/// Defaults to "program" so legacy rows read correctly post-migration.
|
||||
kind String @default("program")
|
||||
/// Streamed-so-far text. Updated periodically by the background
|
||||
/// generator so navigating-away clients can resume display via
|
||||
/// polling. Final value matches `rawResponse` once status flips
|
||||
/// to 'completed' or 'failed'.
|
||||
progressText String?
|
||||
rawResponse String?
|
||||
parsedProgram String? // JSON.stringify of the parsed structure
|
||||
/// JSON.stringify of the parsed structure. An AIProgram when
|
||||
/// kind="program", an AIWorkout when kind="workout".
|
||||
parsedProgram String?
|
||||
provider String
|
||||
model String
|
||||
tokensIn Int?
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
|
||||
|
||||
// Minimal library shape buildPrefillExercises needs (id + cardio inputs).
|
||||
const lib = [
|
||||
{ id: 'press', type: 'barbell', muscleGroups: '["shoulders"]' },
|
||||
{ id: 'bike', type: 'cardio', muscleGroups: '[]' },
|
||||
// Tagged cardio via muscleGroups even though the equipment type isn't.
|
||||
{ id: 'boxjump', type: 'bodyweight', muscleGroups: '["legs","cardio"]' },
|
||||
];
|
||||
|
||||
describe('buildPrefillExercises', () => {
|
||||
it('expands a strength exercise into N sets with weight+reps and RPE only', () => {
|
||||
const draft: AiWorkoutDraft = {
|
||||
name: 'Push',
|
||||
exercises: [
|
||||
{
|
||||
exerciseId: 'press',
|
||||
sets: 4,
|
||||
reps: 6,
|
||||
suggestedWeight: 95,
|
||||
rpe: 8,
|
||||
gear: 3, // wrong-kind value — must be dropped for non-cardio
|
||||
},
|
||||
],
|
||||
};
|
||||
const [ex] = buildPrefillExercises(draft, lib);
|
||||
expect(ex.sets).toHaveLength(4);
|
||||
expect(ex.sets.map((s) => s.setNumber)).toEqual([1, 2, 3, 4]);
|
||||
for (const s of ex.sets) {
|
||||
expect(s.weight).toBe(95);
|
||||
expect(s.reps).toBe(6);
|
||||
expect(s.rpe).toBe(8);
|
||||
expect(s.gear).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses gear (not rpe) for a cardio exercise', () => {
|
||||
const draft: AiWorkoutDraft = {
|
||||
name: 'Conditioning',
|
||||
exercises: [
|
||||
{ exerciseId: 'bike', sets: 1, durationSeconds: 600, gear: 3, rpe: 8 },
|
||||
],
|
||||
};
|
||||
const [ex] = buildPrefillExercises(draft, lib);
|
||||
expect(ex.sets[0].gear).toBe(3);
|
||||
expect(ex.sets[0].rpe).toBeUndefined();
|
||||
expect(ex.sets[0].durationSeconds).toBe(600);
|
||||
});
|
||||
|
||||
it('treats an exercise tagged "cardio" in muscleGroups as cardio', () => {
|
||||
const draft: AiWorkoutDraft = {
|
||||
name: 'Plyo',
|
||||
exercises: [{ exerciseId: 'boxjump', sets: 3, reps: 5, rpe: 7, gear: 2 }],
|
||||
};
|
||||
const [ex] = buildPrefillExercises(draft, lib);
|
||||
expect(ex.sets[0].gear).toBe(2);
|
||||
expect(ex.sets[0].rpe).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults to 3 sets when the count is missing or non-positive', () => {
|
||||
const draft: AiWorkoutDraft = {
|
||||
name: 'X',
|
||||
exercises: [
|
||||
{ exerciseId: 'press', sets: 0, reps: 5, suggestedWeight: 100 },
|
||||
],
|
||||
};
|
||||
const [ex] = buildPrefillExercises(draft, lib);
|
||||
expect(ex.sets).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('drops exercises whose id is not in the library', () => {
|
||||
const draft: AiWorkoutDraft = {
|
||||
name: 'X',
|
||||
exercises: [
|
||||
{ exerciseId: 'ghost', sets: 3, reps: 5 },
|
||||
{ exerciseId: 'press', sets: 2, reps: 5, suggestedWeight: 100 },
|
||||
],
|
||||
};
|
||||
const out = buildPrefillExercises(draft, lib);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].exercise.id).toBe('press');
|
||||
});
|
||||
|
||||
it('puts the coaching note on the first set only', () => {
|
||||
const draft: AiWorkoutDraft = {
|
||||
name: 'X',
|
||||
exercises: [
|
||||
{
|
||||
exerciseId: 'press',
|
||||
sets: 3,
|
||||
reps: 5,
|
||||
suggestedWeight: 100,
|
||||
notes: 'brace hard',
|
||||
},
|
||||
],
|
||||
};
|
||||
const [ex] = buildPrefillExercises(draft, lib);
|
||||
expect(ex.sets[0].notes).toBe('brace hard');
|
||||
expect(ex.sets[1].notes).toBeUndefined();
|
||||
expect(ex.sets[2].notes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAIWorkout } from '@/lib/ai/workoutSchema';
|
||||
|
||||
describe('parseAIWorkout', () => {
|
||||
const valid = {
|
||||
name: 'Upper — Shoulders',
|
||||
notes: 'Overhead press focus',
|
||||
exercises: [
|
||||
{
|
||||
exerciseId: 'cabc',
|
||||
exerciseName: 'Overhead Press',
|
||||
order: 0,
|
||||
sets: 4,
|
||||
reps: 6,
|
||||
suggestedWeight: 95,
|
||||
suggestedWeightUnit: 'lbs',
|
||||
rpe: 8,
|
||||
},
|
||||
{
|
||||
exerciseId: 'cdef',
|
||||
exerciseName: 'Assault Bike',
|
||||
order: 1,
|
||||
sets: 1,
|
||||
durationSeconds: 600,
|
||||
gear: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('accepts a valid single workout', () => {
|
||||
const r = parseAIWorkout(JSON.stringify(valid));
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.workout.name).toBe('Upper — Shoulders');
|
||||
expect(r.workout.exercises).toHaveLength(2);
|
||||
expect(r.workout.exercises[0].suggestedWeight).toBe(95);
|
||||
expect(r.workout.exercises[1].gear).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts null exerciseId for unresolved exercises', () => {
|
||||
const variant = structuredClone(valid);
|
||||
variant.exercises[0].exerciseId = null as unknown as string;
|
||||
const r = parseAIWorkout(JSON.stringify(variant));
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('strips markdown fences and commentary', () => {
|
||||
const wrapped =
|
||||
"Here's today's session:\n\n```json\n" +
|
||||
JSON.stringify(valid) +
|
||||
'\n```\n\nEnjoy!';
|
||||
const r = parseAIWorkout(wrapped);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when no JSON is present', () => {
|
||||
const r = parseAIWorkout('the model just said hi');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
|
||||
});
|
||||
|
||||
it('rejects a parse-level syntax error inside balanced braces', () => {
|
||||
const r = parseAIWorkout('{ "name": "x", }');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
|
||||
});
|
||||
|
||||
it('rejects when the shape is wrong (missing exercises)', () => {
|
||||
const r = parseAIWorkout(JSON.stringify({ name: 'X' }));
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toMatch(/shape/);
|
||||
});
|
||||
|
||||
it('rejects an out-of-range gear', () => {
|
||||
const variant = structuredClone(valid);
|
||||
variant.exercises[1].gear = 9; // gear is 1-5
|
||||
const r = parseAIWorkout(JSON.stringify(variant));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty exercise name', () => {
|
||||
const variant = structuredClone(valid);
|
||||
variant.exercises[0].exerciseName = '';
|
||||
const r = parseAIWorkout(JSON.stringify(variant));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||
getCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/auth', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||
});
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { POST } from '@/app/api/ai/generate-workout/route';
|
||||
|
||||
const URL = 'http://x/api/ai/generate-workout';
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
function rawReq(rawBody: string): NextRequest {
|
||||
return new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: rawBody,
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUser(email: string) {
|
||||
return prisma.user.create({
|
||||
data: { email, passwordHash: 'fake', isAdmin: false },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.aIGeneration.deleteMany();
|
||||
await prisma.userPreferences.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('POST /api/ai/generate-workout — auth + validation', () => {
|
||||
// These all return BEFORE the background runner is kicked off, so no
|
||||
// real provider call happens. We deliberately don't exercise the 201
|
||||
// path (it would spawn a detached generation).
|
||||
|
||||
it('401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
const res = await POST(jsonReq({ userInput: 'upper body' }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('400 on malformed JSON (not 500)', async () => {
|
||||
const user = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
const res = await POST(rawReq('{ not valid json'));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 when userInput is missing', async () => {
|
||||
const user = await makeUser('b@x');
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
const res = await POST(jsonReq({ includeHistory: true }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 when userInput is empty', async () => {
|
||||
const user = await makeUser('c@x');
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
const res = await POST(jsonReq({ userInput: '' }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 with a malformed priorWorkout (fails the shared schema)', async () => {
|
||||
const user = await makeUser('d@x');
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
// priorWorkout missing required `exercises` array → schema rejects.
|
||||
const res = await POST(
|
||||
jsonReq({ userInput: 'tweak it', priorWorkout: { name: 'X' } }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 when the user has no AI provider configured', async () => {
|
||||
const user = await makeUser('e@x');
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
// Valid body, but no UserPreferences row → not configured.
|
||||
const res = await POST(jsonReq({ userInput: 'upper body, shoulders' }));
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,13 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
||||
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN durationMs INTEGER;"
|
||||
fi
|
||||
|
||||
# v1.2.0:6: single-workout generation. `kind` discriminates program vs
|
||||
# workout rows; defaults to "program" so existing rows read correctly.
|
||||
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('AIGeneration');" 2>/dev/null | grep -q "|kind|"; then
|
||||
log "adding AIGeneration.kind (default 'program')"
|
||||
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN kind TEXT NOT NULL DEFAULT 'program';"
|
||||
fi
|
||||
|
||||
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('ProgramExercise');" 2>/dev/null | grep -q "|suggestedWeight|"; then
|
||||
log "adding ProgramExercise.suggestedWeight + suggestedWeightUnit"
|
||||
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeight REAL;"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { v_1_2_0_2 } from './v1.2.0.2'
|
||||
import { v_1_2_0_3 } from './v1.2.0.3'
|
||||
import { v_1_2_0_4 } from './v1.2.0.4'
|
||||
import { v_1_2_0_5 } from './v1.2.0.5'
|
||||
import { v_1_2_0_6 } from './v1.2.0.6'
|
||||
|
||||
/**
|
||||
* Version graph for the `proof-of-work` package.
|
||||
@@ -80,9 +81,14 @@ import { v_1_2_0_5 } from './v1.2.0.5'
|
||||
* v1.2.0:5 — Gear (breathing, 1-5, Brian MacKenzie) replaces RPE as the effort
|
||||
* field for cardio exercises (type "cardio" or "cardio" muscle
|
||||
* group); strength keeps RPE. New SetLog.gear column via boot ALTER.
|
||||
* v1.2.0:6 — AI "generate today's workout": describe a single session and get
|
||||
* a ready-to-log workout (suggested weights/reps from history),
|
||||
* inline-edit + refine-with-AI, then pre-fill the workout log.
|
||||
* Reuses the generation spine via a new AIGeneration.kind
|
||||
* discriminant (boot ALTER, default "program"). No data changes.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_2_0_5,
|
||||
current: v_1_2_0_6,
|
||||
other: [
|
||||
v_1_0_0_1,
|
||||
v_1_0_0_2,
|
||||
@@ -104,5 +110,6 @@ export const versionGraph = VersionGraph.of({
|
||||
v_1_2_0_2,
|
||||
v_1_2_0_3,
|
||||
v_1_2_0_4,
|
||||
v_1_2_0_5,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
/**
|
||||
* v1.2.0:6 — AI "generate today's workout" (2026-06-19).
|
||||
*
|
||||
* A new AI flow alongside program generation: describe a single session in
|
||||
* plain words and get a ready-to-log workout back — exercises with suggested
|
||||
* weights + target reps + set counts grounded in recent history. Inline-edit
|
||||
* it, send a follow-up to refine it via the model, then "Use this workout" to
|
||||
* pre-fill the normal New Workout form (nothing persists until you save).
|
||||
*
|
||||
* Reuses the existing generation spine (detached runner / SSE / lenient JSON /
|
||||
* providers / history context) via a new AIGeneration.kind discriminant
|
||||
* ("program" | "workout"). Single-workout rows are ephemeral and excluded from
|
||||
* the program-shaped AI history.
|
||||
*
|
||||
* Additive schema change: the new AIGeneration.kind column (default "program")
|
||||
* is added by the boot-time guarded ALTER in docker_entrypoint.sh, so this
|
||||
* migration stays empty like every other column add. Existing rows read as
|
||||
* "program"; no data changes.
|
||||
*/
|
||||
export const v_1_2_0_6 = VersionInfo.of({
|
||||
version: '1.2.0:6',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'New: AI "Today\'s workout". Describe a session in plain words and get a ready-to-log workout with suggested weights and reps from your history. Edit it, refine it with the AI, then pre-fill the workout log. No data changes.',
|
||||
},
|
||||
migrations: {
|
||||
up: async () => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user