From 2b0abad68e8d0d702aafd536907a3f374eef03a3 Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 19 Jun 2026 10:59:12 -0500 Subject: [PATCH] =?UTF-8?q?v1.2.0:6=20=E2=80=94=20AI=20"generate=20today's?= =?UTF-8?q?=20workout"=20from=20a=20brain-dump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 8 +- docs/guides/ai-subsystem.md | 23 + .../app/api/ai/generate-workout/route.ts | 149 +++++ proof-of-work/app/api/ai/generate/route.ts | 1 + proof-of-work/app/api/ai/generations/route.ts | 3 +- .../app/main/ai/generate-workout/page.tsx | 70 ++ .../app/main/ai/history/[id]/page.tsx | 3 +- proof-of-work/app/main/ai/history/page.tsx | 5 +- proof-of-work/app/main/ai/page.tsx | 10 +- proof-of-work/app/main/navigation.tsx | 3 +- proof-of-work/app/main/workouts/new/page.tsx | 22 +- .../components/ai/GenerateWorkoutClient.tsx | 626 ++++++++++++++++++ .../components/workouts/AiWorkoutPrefill.tsx | 70 ++ .../components/workouts/WorkoutForm.tsx | 13 +- proof-of-work/lib/ai/generationRunner.ts | 10 +- proof-of-work/lib/ai/workoutDraft.ts | 78 +++ proof-of-work/lib/ai/workoutPrompt.ts | 86 +++ proof-of-work/lib/ai/workoutSchema.ts | 124 ++++ proof-of-work/prisma/schema.prisma | 9 +- proof-of-work/tests/ai-workoutDraft.test.ts | 103 +++ proof-of-work/tests/ai-workoutSchema.test.ts | 88 +++ proof-of-work/tests/routes-ai-workout.test.ts | 96 +++ start9/0.4/docker_entrypoint.sh | 7 + start9/0.4/startos/versions/index.ts | 9 +- start9/0.4/startos/versions/v1.2.0.6.ts | 32 + 25 files changed, 1625 insertions(+), 23 deletions(-) create mode 100644 proof-of-work/app/api/ai/generate-workout/route.ts create mode 100644 proof-of-work/app/main/ai/generate-workout/page.tsx create mode 100644 proof-of-work/components/ai/GenerateWorkoutClient.tsx create mode 100644 proof-of-work/components/workouts/AiWorkoutPrefill.tsx create mode 100644 proof-of-work/lib/ai/workoutDraft.ts create mode 100644 proof-of-work/lib/ai/workoutPrompt.ts create mode 100644 proof-of-work/lib/ai/workoutSchema.ts create mode 100644 proof-of-work/tests/ai-workoutDraft.test.ts create mode 100644 proof-of-work/tests/ai-workoutSchema.test.ts create mode 100644 proof-of-work/tests/routes-ai-workout.test.ts create mode 100644 start9/0.4/startos/versions/v1.2.0.6.ts diff --git a/AGENTS.md b/AGENTS.md index f364a46..933d9f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/docs/guides/ai-subsystem.md b/docs/guides/ai-subsystem.md index 310ae8b..e9c1272 100644 --- a/docs/guides/ai-subsystem.md +++ b/docs/guides/ai-subsystem.md @@ -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` / diff --git a/proof-of-work/app/api/ai/generate-workout/route.ts b/proof-of-work/app/api/ai/generate-workout/route.ts new file mode 100644 index 0000000..d1184fd --- /dev/null +++ b/proof-of-work/app/api/ai/generate-workout/route.ts @@ -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 }); +} diff --git a/proof-of-work/app/api/ai/generate/route.ts b/proof-of-work/app/api/ai/generate/route.ts index 19fea5f..ffd7e99 100644 --- a/proof-of-work/app/api/ai/generate/route.ts +++ b/proof-of-work/app/api/ai/generate/route.ts @@ -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, diff --git a/proof-of-work/app/api/ai/generations/route.ts b/proof-of-work/app/api/ai/generations/route.ts index 1a61b35..ab74d15 100644 --- a/proof-of-work/app/api/ai/generations/route.ts +++ b/proof-of-work/app/api/ai/generations/route.ts @@ -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, diff --git a/proof-of-work/app/main/ai/generate-workout/page.tsx b/proof-of-work/app/main/ai/generate-workout/page.tsx new file mode 100644 index 0000000..342306d --- /dev/null +++ b/proof-of-work/app/main/ai/generate-workout/page.tsx @@ -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 ( +
+
+
+ + + +

+ AI · Today's workout +

+
+
+
+ {!aiConfigured ? ( +
+

AI is not configured.

+

+ Pick a provider, model, and (if needed) API key in{' '} + + Settings → AI integration + {' '} + before you can generate a workout. +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/proof-of-work/app/main/ai/history/[id]/page.tsx b/proof-of-work/app/main/ai/history/[id]/page.tsx index d70aed1..3f80b8b 100644 --- a/proof-of-work/app/main/ai/history/[id]/page.tsx +++ b/proof-of-work/app/main/ai/history/[id]/page.tsx @@ -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 }, diff --git a/proof-of-work/app/main/ai/history/page.tsx b/proof-of-work/app/main/ai/history/page.tsx index a0cb204..4509b26 100644 --- a/proof-of-work/app/main/ai/history/page.tsx +++ b/proof-of-work/app/main/ai/history/page.tsx @@ -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: { diff --git a/proof-of-work/app/main/ai/page.tsx b/proof-of-work/app/main/ai/page.tsx index e85ede6..e2fcaf3 100644 --- a/proof-of-work/app/main/ai/page.tsx +++ b/proof-of-work/app/main/ai/page.tsx @@ -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, diff --git a/proof-of-work/app/main/navigation.tsx b/proof-of-work/app/main/navigation.tsx index f806c8a..277f3ac 100644 --- a/proof-of-work/app/main/navigation.tsx +++ b/proof-of-work/app/main/navigation.tsx @@ -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' }, ], diff --git a/proof-of-work/app/main/workouts/new/page.tsx b/proof-of-work/app/main/workouts/new/page.tsx index c69da17..fa79a13 100644 --- a/proof-of-work/app/main/workouts/new/page.tsx +++ b/proof-of-work/app/main/workouts/new/page.tsx @@ -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 */}
- + {fromAi ? ( + + ) : ( + + )}
); diff --git a/proof-of-work/components/ai/GenerateWorkoutClient.tsx b/proof-of-work/components/ai/GenerateWorkoutClient.tsx new file mode 100644 index 0000000..4ecfd61 --- /dev/null +++ b/proof-of-work/components/ai/GenerateWorkoutClient.tsx @@ -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 | 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({ 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(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 | null = null; + + es.addEventListener('text', (ev) => { + const data = JSON.parse((ev as MessageEvent).data); + raw += data.delta; + const next = lenientJsonParse(raw) as Partial | 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 ( +
+
+ Provider: {providerLabel} + {' · '}Model: {modelLabel} +
+ +
+ +