8f149d35ab
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
3.1 KiB
TypeScript
111 lines
3.1 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { getCurrentUser } from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { getProvider } from '@/lib/ai/providers';
|
|
|
|
/**
|
|
* POST /api/ai/test
|
|
*
|
|
* Sends a tiny "say hi in 3 words" prompt to the user's currently
|
|
* configured AI provider and reports success/failure inline. Lets
|
|
* the operator validate provider/model/key/baseUrl without going
|
|
* through a full program generation.
|
|
*
|
|
* Returns:
|
|
* { ok: true, sample: "Hello there friend", tokensIn?, tokensOut?, ms }
|
|
* { ok: false, error: "..." }
|
|
*
|
|
* Times out after 30s — long enough for cold Ollama starts, short
|
|
* enough that a hung connection doesn't hang the UI.
|
|
*/
|
|
|
|
const TEST_TIMEOUT_MS = 30_000;
|
|
|
|
export async function POST() {
|
|
const user = await getCurrentUser();
|
|
if (!user) {
|
|
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const prefs = await prisma.userPreferences.findUnique({
|
|
where: { userId: user.id },
|
|
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
|
|
});
|
|
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
error: 'Pick a provider + model in Settings → AI integration first.',
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const provider = getProvider(prefs.aiProvider);
|
|
if (!provider) {
|
|
return NextResponse.json(
|
|
{ ok: false, error: `Unknown provider: ${prefs.aiProvider}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
|
const t0 = Date.now();
|
|
|
|
let sample = '';
|
|
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,
|
|
systemPrompt:
|
|
'You are a connectivity test. Reply with exactly three words: "Hello there friend." Nothing else.',
|
|
userPrompt: 'Say hi.',
|
|
signal: controller.signal,
|
|
})) {
|
|
if (chunk.type === 'text') sample += chunk.delta;
|
|
else if (chunk.type === 'usage') {
|
|
tokensIn = chunk.tokensIn;
|
|
tokensOut = chunk.tokensOut;
|
|
} else if (chunk.type === 'error') {
|
|
providerError = chunk.message;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
providerError =
|
|
controller.signal.aborted
|
|
? `Timed out after ${Math.round(TEST_TIMEOUT_MS / 1000)}s`
|
|
: (e as Error).message;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
const ms = Date.now() - t0;
|
|
|
|
if (providerError) {
|
|
return NextResponse.json({ ok: false, error: providerError, ms }, { status: 200 });
|
|
}
|
|
if (!sample.trim()) {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
error:
|
|
'Got an empty response. The model returned successfully but with no text — check the model name and try again.',
|
|
ms,
|
|
},
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
return NextResponse.json({
|
|
ok: true,
|
|
sample: sample.trim().slice(0, 200),
|
|
tokensIn,
|
|
tokensOut,
|
|
ms,
|
|
});
|
|
}
|