dba478aa23
Four incremental upgrades to the AI program generator. No schema change, no /data migration.
1. History as context (the killer feature)
- lib/ai/historyContext.ts builds a 90-day per-exercise rollup:
frequency, recent weights, estimated 1RM (Epley), avg RPE,
days-since-last, plus a STAGNANT flag when the heaviest weight in
the new half doesn't beat the old half.
- Generate page surfaces an "Include my workout history as context"
checkbox (default on at >=10 logged workouts). When checked, the
~1-3 KB summary is appended to the system prompt so the model can
recommend things like "you've stalled bench at 245 — try paused reps."
- We deliberately don't ship raw set logs (privacy + token cost).
2. Test connection
- POST /api/ai/test sends a tiny "say hi in 3 words" prompt and
reports latency + first sample, or the error inline.
- "Test connection" button next to "Save AI config" in
Settings -> AI integration. Verifies provider/model/key/baseUrl
without going through full program generation.
3. Cost estimator
- lib/ai/pricing.ts ships a price table for major models
(Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5).
Ollama always returns 0; openai-compatible returns null.
- Generation history shows per-row cost + a 30-day rolling total
at the top of the page.
4. Streaming preview render
- lib/ai/lenientJson.ts: stack-aware partial-JSON parser that
auto-closes open strings/brackets/braces in reverse-of-opening
order, drops dangling key:value pairs and partial keywords.
Returns a best-effort snapshot of the program-so-far on each chunk.
- Generate UI now renders a live "Building program..." panel that
updates as weeks/days/exercises arrive instead of just showing
raw text and waiting for stream end.
Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts,
ai-pricing.test.ts). 161 total pass.
284 lines
8.9 KiB
TypeScript
284 lines
8.9 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { z } from 'zod';
|
|
import { getCurrentUser } from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { getProvider } from '@/lib/ai/providers';
|
|
import {
|
|
PROGRAM_OUTPUT_SHAPE,
|
|
parseAIProgram,
|
|
} from '@/lib/ai/programSchema';
|
|
import {
|
|
buildHistorySummary,
|
|
formatHistoryContext,
|
|
} from '@/lib/ai/historyContext';
|
|
|
|
/**
|
|
* POST /api/ai/generate
|
|
*
|
|
* Body: { templateId?: string, userInput: string }
|
|
*
|
|
* Streams the model response as Server-Sent Events:
|
|
* event: generation data: {"id":"...generationId..."}
|
|
* event: text data: {"delta":"..."}
|
|
* event: usage data: {"tokensIn":N,"tokensOut":M}
|
|
* event: complete data: {"parsedOk":true|false,"errorMessage":"..."}
|
|
*
|
|
* Reads the user's AI provider config from UserPreferences. The full
|
|
* library of exercises is appended to the system prompt so the model
|
|
* picks real exercise IDs.
|
|
*
|
|
* On error (no provider configured, model error, etc.) emits a single
|
|
* `event: error` and closes.
|
|
*
|
|
* Always writes one AIGeneration row, regardless of success — so the
|
|
* History page can show failed attempts too.
|
|
*/
|
|
|
|
const bodySchema = z.object({
|
|
templateId: z.string().optional().nullable(),
|
|
userInput: z.string().min(1),
|
|
/**
|
|
* When true, build + append a compact summary of the user's
|
|
* recent (90-day) workout history to the system prompt. Lets the
|
|
* model design around stagnations, current strength levels, and
|
|
* actual training frequency.
|
|
*/
|
|
includeHistory: z.boolean().optional().default(false),
|
|
});
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const user = await getCurrentUser();
|
|
if (!user) {
|
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
status: 401,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const body = await request.json().catch(() => ({}));
|
|
const parsed = bodySchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Invalid body',
|
|
details: parsed.error.errors,
|
|
}),
|
|
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
// Load the user's AI provider config.
|
|
const prefs = await prisma.userPreferences.findUnique({
|
|
where: { userId: user.id },
|
|
});
|
|
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error:
|
|
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
|
}),
|
|
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
const provider = getProvider(prefs.aiProvider);
|
|
if (!provider) {
|
|
return new Response(
|
|
JSON.stringify({ error: `Unknown provider: ${prefs.aiProvider}` }),
|
|
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
// Load the template if provided, else use a no-op default.
|
|
let template:
|
|
| {
|
|
id: string;
|
|
name: string;
|
|
systemPrompt: string;
|
|
userPromptTemplate: string;
|
|
}
|
|
| null = null;
|
|
if (parsed.data.templateId) {
|
|
const t = await prisma.aIPromptTemplate.findFirst({
|
|
where: {
|
|
id: parsed.data.templateId,
|
|
OR: [{ userId: user.id }, { userId: null }],
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
systemPrompt: true,
|
|
userPromptTemplate: true,
|
|
},
|
|
});
|
|
if (!t) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Template not found.' }),
|
|
{ status: 404, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
template = t;
|
|
}
|
|
|
|
// Load the user's exercise library to embed in the system prompt.
|
|
const exercises = await prisma.exercise.findMany({
|
|
where: { userId: user.id },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
muscleGroups: true,
|
|
},
|
|
});
|
|
const libraryJson = JSON.stringify(
|
|
exercises.map((e) => ({
|
|
id: e.id,
|
|
name: e.name,
|
|
type: e.type,
|
|
muscleGroups: (() => {
|
|
try {
|
|
return JSON.parse(e.muscleGroups);
|
|
} catch {
|
|
return [];
|
|
}
|
|
})(),
|
|
})),
|
|
);
|
|
|
|
// If requested, build the workout-history summary block.
|
|
let historyBlock = '';
|
|
if (parsed.data.includeHistory) {
|
|
const summary = await buildHistorySummary(prisma, user.id);
|
|
historyBlock = formatHistoryContext(summary);
|
|
}
|
|
|
|
// Stitch the final system + user prompts.
|
|
const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
const systemPrompt = `${baseSystem}
|
|
|
|
OUTPUT SHAPE — emit ONLY a JSON object matching this shape (no commentary, no markdown fences):
|
|
${PROGRAM_OUTPUT_SHAPE}
|
|
|
|
LIBRARY — pick exerciseId values from this list when possible. If you need an exercise the user doesn't have, set exerciseId to null and put the proposed name in exerciseName; the user will resolve it during preview.
|
|
${libraryJson}${historyBlock}`;
|
|
|
|
const userPromptBody =
|
|
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
|
parsed.data.userInput;
|
|
|
|
// Persist the pending row up front so the user can see it in
|
|
// history even if the stream dies mid-flight.
|
|
const generation = await prisma.aIGeneration.create({
|
|
data: {
|
|
userId: user.id,
|
|
templateId: template?.id ?? null,
|
|
templateName: template?.name ?? null,
|
|
userInput: parsed.data.userInput,
|
|
systemPrompt,
|
|
userPrompt: userPromptBody,
|
|
provider: provider.id,
|
|
model: prefs.aiModel,
|
|
status: 'pending',
|
|
},
|
|
});
|
|
|
|
// Stream the model output as SSE.
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
const send = (event: string, data: unknown) =>
|
|
controller.enqueue(
|
|
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
|
);
|
|
send('generation', { id: generation.id });
|
|
|
|
let raw = '';
|
|
let tokensIn: number | undefined;
|
|
let tokensOut: number | undefined;
|
|
let providerError: string | null = null;
|
|
|
|
try {
|
|
for await (const chunk of provider.generate({
|
|
apiKey: prefs.aiApiKey,
|
|
baseUrl: prefs.aiBaseUrl,
|
|
model: prefs.aiModel!, // validated non-null at top of POST
|
|
systemPrompt,
|
|
userPrompt: userPromptBody,
|
|
signal: request.signal,
|
|
})) {
|
|
if (chunk.type === 'text') {
|
|
raw += chunk.delta;
|
|
send('text', { delta: chunk.delta });
|
|
} else if (chunk.type === 'usage') {
|
|
tokensIn = chunk.tokensIn;
|
|
tokensOut = chunk.tokensOut;
|
|
} else if (chunk.type === 'error') {
|
|
providerError = chunk.message;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
providerError = (e as Error).message;
|
|
}
|
|
|
|
// Parse + validate the assembled response.
|
|
let parsedOk = false;
|
|
let parseErr: string | null = null;
|
|
let parsedJson: string | null = null;
|
|
if (!providerError && raw) {
|
|
const r = parseAIProgram(raw);
|
|
if (r.ok) {
|
|
parsedOk = true;
|
|
parsedJson = JSON.stringify(r.program);
|
|
} else {
|
|
parseErr = r.reason;
|
|
}
|
|
}
|
|
|
|
// Persist the final state.
|
|
const status = providerError
|
|
? 'failed'
|
|
: parsedOk
|
|
? 'completed'
|
|
: 'failed';
|
|
const errorMessage =
|
|
providerError ?? (parsedOk ? null : parseErr ?? 'Empty response');
|
|
await prisma.aIGeneration.update({
|
|
where: { id: generation.id },
|
|
data: {
|
|
rawResponse: raw || null,
|
|
parsedProgram: parsedJson,
|
|
tokensIn: tokensIn ?? null,
|
|
tokensOut: tokensOut ?? null,
|
|
status,
|
|
errorMessage,
|
|
},
|
|
});
|
|
|
|
send('usage', { tokensIn, tokensOut });
|
|
send('complete', { parsedOk, errorMessage });
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-store',
|
|
'x-accel-buffering': 'no', // disable nginx buffering if proxied
|
|
},
|
|
});
|
|
}
|
|
|
|
const DEFAULT_SYSTEM_PROMPT = `You are a strength and conditioning coach. The user will describe what they want; you produce a complete training program as JSON.
|
|
|
|
Constraints:
|
|
- Pick exercises from the LIBRARY below by their id. Prefer compound lifts for primary slots and accessories for the back half of each session.
|
|
- Keep volume reasonable: 4-7 exercises per session, 60-75 minutes total.
|
|
- Use rep ranges that match the goal: hypertrophy 6-12, strength 3-6, power 1-5.
|
|
- For each exercise specify sets + reps (range or single) + rest in seconds. RPE is optional but useful for intensity-based programs.
|
|
- If the user asks for something a single library exercise can't satisfy, pick the closest fit and add a coaching note explaining the variation.
|
|
|
|
If you cannot produce a complete program for any reason, emit a JSON object with the durationWeeks and weeks arrays best-effort and add a top-level "description" explaining the gap.`;
|