v1.1.0:3 — AI upgrades: history context, test connection, cost estimator, streaming preview
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.
This commit is contained in:
@@ -7,6 +7,10 @@ import {
|
||||
PROGRAM_OUTPUT_SHAPE,
|
||||
parseAIProgram,
|
||||
} from '@/lib/ai/programSchema';
|
||||
import {
|
||||
buildHistorySummary,
|
||||
formatHistoryContext,
|
||||
} from '@/lib/ai/historyContext';
|
||||
|
||||
/**
|
||||
* POST /api/ai/generate
|
||||
@@ -33,6 +37,13 @@ import {
|
||||
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';
|
||||
@@ -135,6 +146,13 @@ export async function POST(request: NextRequest) {
|
||||
})),
|
||||
);
|
||||
|
||||
// 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}
|
||||
@@ -143,7 +161,7 @@ OUTPUT SHAPE — emit ONLY a JSON object matching this shape (no commentary, no
|
||||
${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}`;
|
||||
${libraryJson}${historyBlock}`;
|
||||
|
||||
const userPromptBody =
|
||||
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default async function GeneratePage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/auth/login');
|
||||
|
||||
const [templates, exercises, prefs] = await Promise.all([
|
||||
const [templates, exercises, prefs, workoutCount] = await Promise.all([
|
||||
prisma.aIPromptTemplate.findMany({
|
||||
where: { OR: [{ userId: null }, { userId: user.id }] },
|
||||
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
|
||||
@@ -31,6 +31,9 @@ export default async function GeneratePage() {
|
||||
where: { userId: user.id },
|
||||
select: { aiProvider: true, aiModel: true },
|
||||
}),
|
||||
prisma.workout.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||
@@ -74,6 +77,7 @@ export default async function GeneratePage() {
|
||||
exercises={exercises}
|
||||
providerLabel={prefs!.aiProvider!}
|
||||
modelLabel={prefs!.aiModel!}
|
||||
workoutCount={workoutCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Sparkles, Square } from 'lucide-react';
|
||||
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||
|
||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
@@ -52,7 +53,7 @@ interface AIProgram {
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'streaming'; raw: string }
|
||||
| { kind: 'streaming'; raw: string; partial: Partial<AIProgram> | null }
|
||||
| { kind: 'parsed'; raw: string; program: AIProgram }
|
||||
| { kind: 'failed'; raw: string; message: string };
|
||||
|
||||
@@ -61,15 +62,18 @@ export default function GenerateClient({
|
||||
exercises,
|
||||
providerLabel,
|
||||
modelLabel,
|
||||
workoutCount,
|
||||
}: {
|
||||
templates: Template[];
|
||||
exercises: LibraryExercise[];
|
||||
providerLabel: string;
|
||||
modelLabel: string;
|
||||
workoutCount: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
|
||||
const [userInput, setUserInput] = useState('');
|
||||
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 10);
|
||||
const [generationId, setGenerationId] = useState<string | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
||||
const [tokens, setTokens] = useState<{ in?: number; out?: number }>({});
|
||||
@@ -82,7 +86,7 @@ export default function GenerateClient({
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!userInput.trim()) return;
|
||||
setPhase({ kind: 'streaming', raw: '' });
|
||||
setPhase({ kind: 'streaming', raw: '', partial: null });
|
||||
setGenerationId(null);
|
||||
setTokens({});
|
||||
|
||||
@@ -95,6 +99,7 @@ export default function GenerateClient({
|
||||
body: JSON.stringify({
|
||||
templateId: templateId || null,
|
||||
userInput,
|
||||
includeHistory,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
@@ -146,7 +151,8 @@ export default function GenerateClient({
|
||||
setGenerationId(parsed.id);
|
||||
} else if (evtName === 'text') {
|
||||
raw += parsed.delta;
|
||||
setPhase({ kind: 'streaming', raw });
|
||||
const partial = lenientJsonParse(raw) as Partial<AIProgram> | null;
|
||||
setPhase({ kind: 'streaming', raw, partial });
|
||||
} else if (evtName === 'usage') {
|
||||
setTokens({ in: parsed.tokensIn, out: parsed.tokensOut });
|
||||
} else if (evtName === 'complete') {
|
||||
@@ -256,6 +262,25 @@ export default function GenerateClient({
|
||||
/>
|
||||
</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={phase.kind === 'streaming' || workoutCount === 0}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Include my workout history as context{' '}
|
||||
<span className="text-zinc-500">
|
||||
({workoutCount === 0
|
||||
? 'no workouts logged yet — disabled'
|
||||
: `last 90 days · summarizes per-exercise frequency, recent weights, stagnations`}
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{phase.kind === 'streaming' ? (
|
||||
<button
|
||||
@@ -294,10 +319,23 @@ export default function GenerateClient({
|
||||
</div>
|
||||
|
||||
{phase.kind === 'streaming' && (
|
||||
<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">
|
||||
{phase.raw || '(waiting for first token...)'}
|
||||
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
|
||||
</div>
|
||||
<>
|
||||
{phase.partial ? (
|
||||
<PartialPreview partial={phase.partial} />
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500 italic">
|
||||
Waiting for the first parseable JSON...
|
||||
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
|
||||
</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' && (
|
||||
@@ -613,3 +651,47 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PartialPreview({ partial }: { partial: Partial<AIProgram> }) {
|
||||
const weeks = (partial.weeks as AIWeek[] | 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 program...{' '}
|
||||
{partial.name && (
|
||||
<span className="text-white font-semibold">{partial.name}</span>
|
||||
)}
|
||||
{partial.type && (
|
||||
<span className="text-zinc-500"> · {partial.type}</span>
|
||||
)}
|
||||
{typeof partial.durationWeeks === 'number' && (
|
||||
<span className="text-zinc-500"> · {partial.durationWeeks} wk</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{weeks.length > 0 && (
|
||||
<ul className="text-xs text-zinc-300 space-y-1">
|
||||
{weeks.map((w, i) => (
|
||||
<li key={i}>
|
||||
<span className="text-zinc-500">Week {w?.weekNumber ?? '?'}:</span>{' '}
|
||||
{Array.isArray(w?.days)
|
||||
? `${w.days.length} day${w.days.length === 1 ? '' : 's'} (${
|
||||
w.days.reduce(
|
||||
(n: number, d: AIDay) =>
|
||||
n + (Array.isArray(d?.exercises) ? d.exercises.length : 0),
|
||||
0,
|
||||
)
|
||||
} exercises)`
|
||||
: '...'}
|
||||
{w?.phase && (
|
||||
<span className="text-zinc-500"> · {w.phase}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
@@ -26,6 +27,34 @@ export default function HistoryList({
|
||||
const [rows, setRows] = useState(initialRows);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
// Per-row cost + 30-day rolling total. Pricing is best-effort
|
||||
// (Ollama = free, openai-compatible = unknown, others priced
|
||||
// from lib/ai/pricing.ts). Free + unknown both contribute 0 to
|
||||
// the total so it's a lower bound at worst.
|
||||
const rowsWithCost = useMemo(
|
||||
() =>
|
||||
rows.map((r) => ({
|
||||
...r,
|
||||
costUsd: estimateCost({
|
||||
provider: r.provider,
|
||||
model: r.model,
|
||||
tokensIn: r.tokensIn,
|
||||
tokensOut: r.tokensOut,
|
||||
}),
|
||||
})),
|
||||
[rows],
|
||||
);
|
||||
const totalLast30Days = useMemo(() => {
|
||||
const cutoff = Date.now() - 30 * 86_400_000;
|
||||
let total = 0;
|
||||
for (const r of rowsWithCost) {
|
||||
if (r.costUsd != null && new Date(r.createdAt).getTime() >= cutoff) {
|
||||
total += r.costUsd;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}, [rowsWithCost]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this generation? The applied Program (if any) stays.'))
|
||||
return;
|
||||
@@ -48,8 +77,17 @@ export default function HistoryList({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-zinc-500 mb-4 uppercase tracking-wider">
|
||||
Estimated cost (last 30 days):{' '}
|
||||
<span className="text-zinc-200">{formatCost(totalLast30Days)}</span>
|
||||
<span className="text-zinc-600">
|
||||
{' '}
|
||||
· Ollama + custom-URL gateways excluded
|
||||
</span>
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{rows.map((r) => (
|
||||
{rowsWithCost.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="bg-zinc-900 border border-zinc-800 rounded p-4"
|
||||
@@ -71,6 +109,14 @@ export default function HistoryList({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{r.costUsd != null && (
|
||||
<>
|
||||
<span className="text-zinc-600">·</span>
|
||||
<span title="Estimated USD cost based on the model's published per-token pricing">
|
||||
{formatCost(r.costUsd)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{r.templateName && (
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
@@ -111,6 +157,7 @@ export default function HistoryList({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,18 @@ export default function AIIntegration() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<
|
||||
| null
|
||||
| {
|
||||
ok: true;
|
||||
sample: string;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
ms: number;
|
||||
}
|
||||
| { ok: false; error: string; ms?: number }
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/ai/config')
|
||||
@@ -44,6 +56,20 @@ export default function AIIntegration() {
|
||||
|
||||
const meta = PROVIDERS.find((p) => p.id === provider);
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch('/api/ai/test', { method: 'POST' });
|
||||
const body = await res.json();
|
||||
setTestResult(body);
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, error: (e as Error).message });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -186,21 +212,64 @@ export default function AIIntegration() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 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"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save AI config'
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || testing}
|
||||
className="px-4 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"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save AI config'
|
||||
)}
|
||||
</button>
|
||||
{provider && cfg?.aiProvider === provider && cfg?.aiModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={saving || testing}
|
||||
className="px-4 py-2 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs uppercase tracking-wider disabled:opacity-50"
|
||||
title="Send a tiny prompt to verify the configured provider responds"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-2" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
'Test connection'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded px-3 py-2 border text-xs ${
|
||||
testResult.ok
|
||||
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
|
||||
: 'bg-red-900/50 border-red-800 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<>
|
||||
✓ Connected in {testResult.ms}ms
|
||||
{testResult.tokensIn != null &&
|
||||
` · ${testResult.tokensIn} in / ${testResult.tokensOut ?? '?'} out tokens`}
|
||||
<div className="mt-1 text-zinc-400">
|
||||
Sample reply: <span className="text-zinc-200">{testResult.sample}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>✗ {testResult.error}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Build a compact workout-history summary the AI can use as
|
||||
* context for personalized program generation.
|
||||
*
|
||||
* We DELIBERATELY don't ship raw set logs — that would be tens of
|
||||
* KB per request and burn tokens. Instead we compute per-exercise
|
||||
* aggregates over a recent window (default 90 days):
|
||||
*
|
||||
* - totalSets in window
|
||||
* - distinct workouts
|
||||
* - daysSinceLast
|
||||
* - lastWeight, lastReps (from the most-recent set)
|
||||
* - bestWeight (heaviest set in window)
|
||||
* - estimated 1RM (Epley formula on the heaviest weighted set)
|
||||
* - rpe trend (avg RPE over recent sets, if logged)
|
||||
* - stagnation flag (heaviest weight unchanged for 4+ weeks AND
|
||||
* ≥3 sessions of that exercise in those 4+ weeks)
|
||||
*
|
||||
* Plus a top-level summary: total workouts, frequency, primary
|
||||
* exercise types touched.
|
||||
*
|
||||
* The output is JSON-stringifiable, ~5-15 KB for a typical user.
|
||||
*/
|
||||
|
||||
export interface HistoryExerciseSummary {
|
||||
name: string;
|
||||
type: string;
|
||||
totalSets: number;
|
||||
distinctWorkouts: number;
|
||||
daysSinceLast: number;
|
||||
lastWeight: number | null;
|
||||
lastReps: number | null;
|
||||
bestWeight: number | null;
|
||||
estimated1RM: number | null;
|
||||
avgRpe: number | null;
|
||||
stagnant: boolean;
|
||||
}
|
||||
|
||||
export interface HistorySummary {
|
||||
windowDays: number;
|
||||
totalWorkouts: number;
|
||||
workoutsPerWeek: number;
|
||||
primaryTypes: string[]; // exercise types by descending volume
|
||||
exercises: HistoryExerciseSummary[];
|
||||
}
|
||||
|
||||
/** Epley estimated 1RM: weight * (1 + reps / 30) */
|
||||
function epley1RM(weight: number, reps: number): number {
|
||||
return Math.round(weight * (1 + reps / 30));
|
||||
}
|
||||
|
||||
export async function buildHistorySummary(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
windowDays = 90,
|
||||
): Promise<HistorySummary> {
|
||||
const cutoff = new Date(Date.now() - windowDays * 86_400_000);
|
||||
|
||||
// Pull every set log in the window with its exercise + workout
|
||||
// date. One query, one result-set walk.
|
||||
const sets = await prisma.setLog.findMany({
|
||||
where: {
|
||||
workout: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: { gte: cutoff },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
reps: true,
|
||||
weight: true,
|
||||
rpe: true,
|
||||
exerciseId: true,
|
||||
workoutId: true,
|
||||
workout: { select: { date: true } },
|
||||
exercise: { select: { name: true, type: true } },
|
||||
},
|
||||
orderBy: { workout: { date: 'desc' } },
|
||||
});
|
||||
|
||||
if (sets.length === 0) {
|
||||
return {
|
||||
windowDays,
|
||||
totalWorkouts: 0,
|
||||
workoutsPerWeek: 0,
|
||||
primaryTypes: [],
|
||||
exercises: [],
|
||||
};
|
||||
}
|
||||
|
||||
const workoutIds = new Set(sets.map((s) => s.workoutId));
|
||||
const totalWorkouts = workoutIds.size;
|
||||
const weeks = windowDays / 7;
|
||||
const workoutsPerWeek = Math.round((totalWorkouts / weeks) * 10) / 10;
|
||||
|
||||
// Group by exercise
|
||||
const byExercise = new Map<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
type: string;
|
||||
sets: typeof sets;
|
||||
}
|
||||
>();
|
||||
for (const s of sets) {
|
||||
if (!byExercise.has(s.exerciseId)) {
|
||||
byExercise.set(s.exerciseId, {
|
||||
name: s.exercise.name,
|
||||
type: s.exercise.type,
|
||||
sets: [],
|
||||
});
|
||||
}
|
||||
byExercise.get(s.exerciseId)!.sets.push(s);
|
||||
}
|
||||
|
||||
// Per-exercise summaries
|
||||
const now = Date.now();
|
||||
const exercises: HistoryExerciseSummary[] = [];
|
||||
for (const [, group] of byExercise) {
|
||||
const groupSets = group.sets;
|
||||
const distinctWorkouts = new Set(groupSets.map((s) => s.workoutId)).size;
|
||||
const mostRecent = groupSets[0]; // already date-desc
|
||||
const daysSinceLast = Math.floor(
|
||||
(now - mostRecent.workout.date.getTime()) / 86_400_000,
|
||||
);
|
||||
|
||||
const weightedSets = groupSets.filter(
|
||||
(s): s is typeof s & { weight: number; reps: number } =>
|
||||
typeof s.weight === 'number' && typeof s.reps === 'number',
|
||||
);
|
||||
const bestWeightSet = weightedSets.reduce<
|
||||
| { weight: number; reps: number }
|
||||
| null
|
||||
>((best, s) => {
|
||||
if (!best || s.weight > best.weight) return s;
|
||||
return best;
|
||||
}, null);
|
||||
const bestWeight = bestWeightSet?.weight ?? null;
|
||||
const estimated1RM =
|
||||
bestWeightSet != null ? epley1RM(bestWeightSet.weight, bestWeightSet.reps) : null;
|
||||
|
||||
const rpeSets = groupSets.filter(
|
||||
(s): s is typeof s & { rpe: number } => typeof s.rpe === 'number',
|
||||
);
|
||||
const avgRpe =
|
||||
rpeSets.length > 0
|
||||
? Math.round(
|
||||
(rpeSets.reduce((sum, s) => sum + s.rpe, 0) / rpeSets.length) * 10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
// Stagnation: best weight in oldest half == best weight in newest half
|
||||
// AND ≥3 distinct sessions in the window.
|
||||
let stagnant = false;
|
||||
if (distinctWorkouts >= 3 && bestWeight != null && weightedSets.length >= 4) {
|
||||
const sortedByDate = [...weightedSets].sort(
|
||||
(a, b) => a.workout.date.getTime() - b.workout.date.getTime(),
|
||||
);
|
||||
const mid = Math.floor(sortedByDate.length / 2);
|
||||
const oldHalfBest = Math.max(...sortedByDate.slice(0, mid).map((s) => s.weight));
|
||||
const newHalfBest = Math.max(...sortedByDate.slice(mid).map((s) => s.weight));
|
||||
// No improvement in the new half compared to the old half
|
||||
if (newHalfBest <= oldHalfBest) stagnant = true;
|
||||
}
|
||||
|
||||
exercises.push({
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
totalSets: groupSets.length,
|
||||
distinctWorkouts,
|
||||
daysSinceLast,
|
||||
lastWeight: mostRecent.weight ?? null,
|
||||
lastReps: mostRecent.reps ?? null,
|
||||
bestWeight,
|
||||
estimated1RM,
|
||||
avgRpe,
|
||||
stagnant,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort exercises by total volume (sets) descending so the most
|
||||
// important context is first if the model truncates.
|
||||
exercises.sort((a, b) => b.totalSets - a.totalSets);
|
||||
|
||||
// Primary types by aggregate sets
|
||||
const typeVolume = new Map<string, number>();
|
||||
for (const ex of exercises) {
|
||||
typeVolume.set(ex.type, (typeVolume.get(ex.type) ?? 0) + ex.totalSets);
|
||||
}
|
||||
const primaryTypes = Array.from(typeVolume.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([t]) => t);
|
||||
|
||||
return {
|
||||
windowDays,
|
||||
totalWorkouts,
|
||||
workoutsPerWeek,
|
||||
primaryTypes,
|
||||
exercises,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a HistorySummary as a compact string the LLM can actually
|
||||
* use. Aims for <2KB of text even for heavy users.
|
||||
*/
|
||||
export function formatHistoryContext(summary: HistorySummary): string {
|
||||
if (summary.totalWorkouts === 0) {
|
||||
return `\nUSER HISTORY: no workouts logged in the last ${summary.windowDays} days.`;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`\nUSER HISTORY (last ${summary.windowDays} days):`,
|
||||
` ${summary.totalWorkouts} workouts (~${summary.workoutsPerWeek}/week)`,
|
||||
` Primary work: ${summary.primaryTypes.slice(0, 4).join(', ')}`,
|
||||
'',
|
||||
` Per-exercise activity (descending by volume; weights in user's logged unit):`,
|
||||
);
|
||||
// Cap at top 30 exercises
|
||||
const top = summary.exercises.slice(0, 30);
|
||||
for (const ex of top) {
|
||||
const bits: string[] = [
|
||||
`${ex.totalSets}s/${ex.distinctWorkouts}w`,
|
||||
`${ex.daysSinceLast}d ago`,
|
||||
];
|
||||
if (ex.bestWeight != null && ex.lastReps != null)
|
||||
bits.push(`best ${ex.bestWeight}×${ex.lastReps}`);
|
||||
if (ex.estimated1RM != null) bits.push(`~${ex.estimated1RM} 1RM`);
|
||||
if (ex.avgRpe != null) bits.push(`avg RPE ${ex.avgRpe}`);
|
||||
if (ex.stagnant) bits.push('STAGNANT');
|
||||
lines.push(` - ${ex.name} (${ex.type}): ${bits.join(' · ')}`);
|
||||
}
|
||||
if (summary.exercises.length > top.length) {
|
||||
lines.push(
|
||||
` ...and ${summary.exercises.length - top.length} more exercises with lower volume`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
` When designing the program, weight recent activity heavily. Address STAGNANT exercises if relevant. Don't propose deload-week-heavy work for someone training infrequently, and don't propose 6-day splits for someone averaging <3 sessions/week.`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Lenient JSON parser for incremental rendering of in-flight LLM
|
||||
* output.
|
||||
*
|
||||
* The model emits JSON one token at a time. Strict JSON.parse fails
|
||||
* until the very last `}` arrives. lenientJsonParse instead:
|
||||
*
|
||||
* 1. Locates the first `{` (after stripping ```json fences).
|
||||
* 2. Walks the buffer tracking quote state + an open-bracket
|
||||
* stack so we know what to close in what order.
|
||||
* 3. Closes any open string with `"`.
|
||||
* 4. Trims a partial trailing keyword (true/false/null prefix),
|
||||
* trailing comma, and dangling key:value pair where value is
|
||||
* missing.
|
||||
* 5. Closes open structures in reverse-of-opening order (so
|
||||
* `[{` closes as `}]`, not `]}`).
|
||||
* 6. JSON.parse the result; return null if it still fails.
|
||||
*
|
||||
* The returned object is a best-effort snapshot of the program so
|
||||
* far. The Generate UI uses it to render a live preview as the
|
||||
* model writes; once the stream ends, the FULL response is parsed
|
||||
* with the strict parser via parseAIProgram for the final render.
|
||||
*
|
||||
* This is intentionally simple — partial numbers (e.g. `-2.`) and
|
||||
* partial escape sequences just return null until the next chunk
|
||||
* makes them well-formed.
|
||||
*/
|
||||
export function lenientJsonParse(raw: string): unknown | null {
|
||||
if (!raw) return null;
|
||||
|
||||
// Strip ```json fences (or plain ``` fences). Tolerates an
|
||||
// unclosed trailing fence (still streaming).
|
||||
let s = raw;
|
||||
const fenced = s.match(/```(?:json)?\s*([\s\S]*?)(?:\s*```|$)/);
|
||||
if (fenced) s = fenced[1];
|
||||
|
||||
// Locate first `{`.
|
||||
const startIdx = s.indexOf('{');
|
||||
if (startIdx < 0) return null;
|
||||
s = s.slice(startIdx);
|
||||
|
||||
// Quick path: maybe it's already valid (rare during streaming,
|
||||
// common after the stream completes).
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// Walk the buffer tracking the open-bracket stack. We don't try
|
||||
// to recover from mismatched closers (would be model malformity);
|
||||
// we just don't pop more than we have.
|
||||
const stack: Array<'{' | '['> = [];
|
||||
let inStr = false;
|
||||
let escape = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inStr = !inStr;
|
||||
continue;
|
||||
}
|
||||
if (inStr) continue;
|
||||
if (c === '{') stack.push('{');
|
||||
else if (c === '}') {
|
||||
if (stack[stack.length - 1] === '{') stack.pop();
|
||||
} else if (c === '[') stack.push('[');
|
||||
else if (c === ']') {
|
||||
if (stack[stack.length - 1] === '[') stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
let candidate = s;
|
||||
|
||||
// Close any open string at the tail.
|
||||
if (inStr) candidate += '"';
|
||||
|
||||
// Trim trailing whitespace.
|
||||
candidate = candidate.replace(/\s+$/, '');
|
||||
|
||||
// Drop a partial trailing keyword (`true`/`false`/`null` prefix)
|
||||
// sitting after a `:`, `,`, or `[`.
|
||||
candidate = candidate.replace(
|
||||
/([:,[])\s*(?:t|tr|tru|f|fa|fal|fals|n|nu|nul)$/,
|
||||
'$1',
|
||||
);
|
||||
|
||||
// Drop a trailing comma (no value follows yet).
|
||||
candidate = candidate.replace(/,\s*$/, '');
|
||||
|
||||
// Drop a dangling key + colon (value not started yet).
|
||||
candidate = candidate.replace(/"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*$/, '');
|
||||
|
||||
// Drop another trailing comma that may now be exposed.
|
||||
candidate = candidate.replace(/,\s*$/, '');
|
||||
|
||||
// Close stack in reverse-of-opening order. `[{` becomes `}]` not
|
||||
// `]}` — that's the bug a depth-counter approach would have.
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()!;
|
||||
candidate += top === '{' ? '}' : ']';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Per-model pricing in USD per million tokens. Used to estimate the
|
||||
* cost of an AIGeneration row from its tokensIn/tokensOut.
|
||||
*
|
||||
* Prices change. This table is a best-effort starting point for
|
||||
* common models as of mid-2026; users on other models will see
|
||||
* `null` cost (we still surface token counts). Updating: edit this
|
||||
* file and ship — no schema change needed.
|
||||
*
|
||||
* Matching strategy: case-insensitive prefix lookup against the
|
||||
* user's configured model string. Model names like
|
||||
* "claude-sonnet-4-5-20251022" match the "claude-sonnet-4-5" prefix.
|
||||
*
|
||||
* Keys are organized by provider for readability but the lookup is
|
||||
* provider-agnostic — the model string is the key.
|
||||
*/
|
||||
|
||||
interface PriceEntry {
|
||||
inputPerM: number; // USD per 1M input tokens
|
||||
outputPerM: number; // USD per 1M output tokens
|
||||
}
|
||||
|
||||
const PRICES: Record<string, PriceEntry> = {
|
||||
// Anthropic Claude (Messages API)
|
||||
'claude-opus-4': { inputPerM: 15, outputPerM: 75 },
|
||||
'claude-opus-4-5': { inputPerM: 15, outputPerM: 75 },
|
||||
'claude-sonnet-4': { inputPerM: 3, outputPerM: 15 },
|
||||
'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15 },
|
||||
'claude-haiku-4': { inputPerM: 0.8, outputPerM: 4 },
|
||||
'claude-haiku-4-5': { inputPerM: 0.8, outputPerM: 4 },
|
||||
'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15 },
|
||||
'claude-3-5-sonnet': { inputPerM: 3, outputPerM: 15 },
|
||||
'claude-3-5-haiku': { inputPerM: 0.8, outputPerM: 4 },
|
||||
|
||||
// OpenAI
|
||||
'gpt-5': { inputPerM: 1.25, outputPerM: 10 },
|
||||
'gpt-5-mini': { inputPerM: 0.25, outputPerM: 2 },
|
||||
'gpt-5-nano': { inputPerM: 0.05, outputPerM: 0.4 },
|
||||
'gpt-4o': { inputPerM: 2.5, outputPerM: 10 },
|
||||
'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.6 },
|
||||
'o1': { inputPerM: 15, outputPerM: 60 },
|
||||
'o3': { inputPerM: 2, outputPerM: 8 },
|
||||
'o3-mini': { inputPerM: 1.1, outputPerM: 4.4 },
|
||||
'o4-mini': { inputPerM: 1.1, outputPerM: 4.4 },
|
||||
|
||||
// Google Gemini
|
||||
'gemini-2.5-pro': { inputPerM: 1.25, outputPerM: 10 },
|
||||
'gemini-2.5-flash': { inputPerM: 0.3, outputPerM: 2.5 },
|
||||
'gemini-2.0-flash': { inputPerM: 0.1, outputPerM: 0.4 },
|
||||
'gemini-2.0-pro': { inputPerM: 1.25, outputPerM: 5 },
|
||||
'gemini-1.5-pro': { inputPerM: 1.25, outputPerM: 5 },
|
||||
'gemini-1.5-flash': { inputPerM: 0.075, outputPerM: 0.3 },
|
||||
};
|
||||
|
||||
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
|
||||
export function findPrice(model: string): PriceEntry | null {
|
||||
const m = model.toLowerCase();
|
||||
// Longest-prefix-first so e.g. "claude-sonnet-4-5" beats "claude-sonnet-4".
|
||||
const sortedKeys = Object.keys(PRICES).sort((a, b) => b.length - a.length);
|
||||
for (const key of sortedKeys) {
|
||||
if (m.startsWith(key.toLowerCase())) return PRICES[key];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the USD cost of a generation. Returns null if the model
|
||||
* isn't in the price table or if either token count is missing.
|
||||
* Ollama and openai-compatible custom gateways always return null
|
||||
* (they're either free or self-priced).
|
||||
*/
|
||||
export function estimateCost(opts: {
|
||||
provider: string;
|
||||
model: string;
|
||||
tokensIn: number | null;
|
||||
tokensOut: number | null;
|
||||
}): number | null {
|
||||
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
|
||||
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
|
||||
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
||||
const price = findPrice(opts.model);
|
||||
if (!price) return null;
|
||||
return (
|
||||
(opts.tokensIn / 1_000_000) * price.inputPerM +
|
||||
(opts.tokensOut / 1_000_000) * price.outputPerM
|
||||
);
|
||||
}
|
||||
|
||||
/** Format USD to a string suitable for a UI label. Below $0.01 -> "<$0.01". */
|
||||
export function formatCost(usd: number | null): string {
|
||||
if (usd == null) return '—';
|
||||
if (usd === 0) return 'free';
|
||||
if (usd < 0.01) return '<$0.01';
|
||||
if (usd < 1) return `$${usd.toFixed(3)}`;
|
||||
return `$${usd.toFixed(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import {
|
||||
buildHistorySummary,
|
||||
formatHistoryContext,
|
||||
} from '@/lib/ai/historyContext';
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.session.deleteMany();
|
||||
await prisma.setLog.deleteMany();
|
||||
await prisma.workout.deleteMany();
|
||||
await prisma.programExercise.deleteMany();
|
||||
await prisma.programDay.deleteMany();
|
||||
await prisma.programWeek.deleteMany();
|
||||
await prisma.program.deleteMany();
|
||||
await prisma.exercise.deleteMany();
|
||||
await prisma.aIGeneration.deleteMany();
|
||||
await prisma.aIPromptTemplate.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.instanceSettings.deleteMany();
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: 'a@x', passwordHash: 'fake' },
|
||||
});
|
||||
const bench = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'Bench Press',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
const squat = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: 'Squat',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
return { user, bench, squat };
|
||||
}
|
||||
|
||||
async function logWorkout(
|
||||
userId: string,
|
||||
daysAgo: number,
|
||||
sets: Array<{ exerciseId: string; reps: number; weight: number; rpe?: number }>,
|
||||
) {
|
||||
const date = new Date(Date.now() - daysAgo * 86_400_000);
|
||||
return prisma.workout.create({
|
||||
data: {
|
||||
userId,
|
||||
date,
|
||||
setLogs: {
|
||||
create: sets.map((s, i) => ({
|
||||
exerciseId: s.exerciseId,
|
||||
setNumber: i + 1,
|
||||
reps: s.reps,
|
||||
weight: s.weight,
|
||||
rpe: s.rpe,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('buildHistorySummary', () => {
|
||||
it('returns empty summary for a user with no workouts', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: 'a@x', passwordHash: 'fake' },
|
||||
});
|
||||
const s = await buildHistorySummary(prisma, user.id);
|
||||
expect(s.totalWorkouts).toBe(0);
|
||||
expect(s.exercises).toEqual([]);
|
||||
});
|
||||
|
||||
it('summarizes a single user\'s recent activity', async () => {
|
||||
const { user, bench, squat } = await setup();
|
||||
// 3 bench sessions, 2 squat sessions in last 30 days
|
||||
await logWorkout(user.id, 1, [
|
||||
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||
]);
|
||||
await logWorkout(user.id, 4, [
|
||||
{ exerciseId: bench.id, reps: 5, weight: 235 },
|
||||
{ exerciseId: bench.id, reps: 5, weight: 235 },
|
||||
]);
|
||||
await logWorkout(user.id, 7, [
|
||||
{ exerciseId: bench.id, reps: 5, weight: 215 },
|
||||
{ exerciseId: squat.id, reps: 5, weight: 315 },
|
||||
]);
|
||||
await logWorkout(user.id, 14, [
|
||||
{ exerciseId: squat.id, reps: 5, weight: 305 },
|
||||
]);
|
||||
|
||||
const s = await buildHistorySummary(prisma, user.id);
|
||||
expect(s.totalWorkouts).toBe(4);
|
||||
expect(s.workoutsPerWeek).toBeGreaterThan(0);
|
||||
expect(s.exercises).toHaveLength(2);
|
||||
|
||||
const benchSummary = s.exercises.find((e) => e.name === 'Bench Press');
|
||||
expect(benchSummary).toBeTruthy();
|
||||
expect(benchSummary!.totalSets).toBe(5);
|
||||
expect(benchSummary!.distinctWorkouts).toBe(3);
|
||||
expect(benchSummary!.bestWeight).toBe(235);
|
||||
expect(benchSummary!.daysSinceLast).toBeLessThanOrEqual(2); // logged 1 day ago
|
||||
|
||||
// Epley(235, 5) = 235 * (1 + 5/30) = 274.17 → 274
|
||||
expect(benchSummary!.estimated1RM).toBe(274);
|
||||
});
|
||||
|
||||
it('flags stagnation on a stuck exercise', async () => {
|
||||
const { user, bench } = await setup();
|
||||
// 6 sessions all at the same weight
|
||||
for (let d = 0; d < 6; d++) {
|
||||
await logWorkout(user.id, d * 5, [
|
||||
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||
]);
|
||||
}
|
||||
const s = await buildHistorySummary(prisma, user.id);
|
||||
const bs = s.exercises.find((e) => e.name === 'Bench Press');
|
||||
expect(bs?.stagnant).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT flag stagnation on a progressing exercise', async () => {
|
||||
const { user, bench } = await setup();
|
||||
// 6 sessions with progressive weight
|
||||
for (let d = 0; d < 6; d++) {
|
||||
await logWorkout(user.id, (5 - d) * 7, [
|
||||
{ exerciseId: bench.id, reps: 5, weight: 200 + d * 10 },
|
||||
]);
|
||||
}
|
||||
const s = await buildHistorySummary(prisma, user.id);
|
||||
const bs = s.exercises.find((e) => e.name === 'Bench Press');
|
||||
expect(bs?.stagnant).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes workouts outside the window', async () => {
|
||||
const { user, bench } = await setup();
|
||||
await logWorkout(user.id, 5, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
|
||||
await logWorkout(user.id, 200, [{ exerciseId: bench.id, reps: 5, weight: 200 }]);
|
||||
const s = await buildHistorySummary(prisma, user.id, 90);
|
||||
expect(s.totalWorkouts).toBe(1);
|
||||
expect(s.exercises[0].totalSets).toBe(1);
|
||||
});
|
||||
|
||||
it('excludes soft-deleted workouts', async () => {
|
||||
const { user, bench } = await setup();
|
||||
const w = await logWorkout(user.id, 3, [
|
||||
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||
]);
|
||||
await prisma.workout.update({
|
||||
where: { id: w.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
const s = await buildHistorySummary(prisma, user.id);
|
||||
expect(s.totalWorkouts).toBe(0);
|
||||
});
|
||||
|
||||
it('isolates per-user data (does not bleed across users)', async () => {
|
||||
const { user, bench } = await setup();
|
||||
const otherUser = await prisma.user.create({
|
||||
data: { email: 'b@x', passwordHash: 'fake' },
|
||||
});
|
||||
const otherBench = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: otherUser.id,
|
||||
name: 'Bench Press',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
await logWorkout(user.id, 1, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
|
||||
await logWorkout(otherUser.id, 1, [
|
||||
{ exerciseId: otherBench.id, reps: 100, weight: 999 },
|
||||
]);
|
||||
const s = await buildHistorySummary(prisma, user.id);
|
||||
expect(s.totalWorkouts).toBe(1);
|
||||
expect(s.exercises[0].bestWeight).toBe(225); // not 999
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHistoryContext', () => {
|
||||
it('emits a friendly message on empty history', () => {
|
||||
const out = formatHistoryContext({
|
||||
windowDays: 90,
|
||||
totalWorkouts: 0,
|
||||
workoutsPerWeek: 0,
|
||||
primaryTypes: [],
|
||||
exercises: [],
|
||||
});
|
||||
expect(out).toMatch(/no workouts/);
|
||||
});
|
||||
|
||||
it('formats a populated summary into a compact block', () => {
|
||||
const out = formatHistoryContext({
|
||||
windowDays: 90,
|
||||
totalWorkouts: 30,
|
||||
workoutsPerWeek: 3.3,
|
||||
primaryTypes: ['barbell', 'dumbbell', 'cable'],
|
||||
exercises: [
|
||||
{
|
||||
name: 'Bench Press',
|
||||
type: 'barbell',
|
||||
totalSets: 36,
|
||||
distinctWorkouts: 12,
|
||||
daysSinceLast: 2,
|
||||
lastWeight: 235,
|
||||
lastReps: 5,
|
||||
bestWeight: 245,
|
||||
estimated1RM: 286,
|
||||
avgRpe: 8.5,
|
||||
stagnant: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toMatch(/30 workouts/);
|
||||
expect(out).toMatch(/Bench Press/);
|
||||
expect(out).toMatch(/STAGNANT|RPE/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||
|
||||
describe('lenientJsonParse', () => {
|
||||
it('returns null on empty input', () => {
|
||||
expect(lenientJsonParse('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no { is present', () => {
|
||||
expect(lenientJsonParse('hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the object when input is already valid', () => {
|
||||
expect(lenientJsonParse('{"a":1,"b":[2,3]}')).toEqual({ a: 1, b: [2, 3] });
|
||||
});
|
||||
|
||||
it('strips ```json fences', () => {
|
||||
expect(lenientJsonParse('```json\n{"a":1}\n```')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('handles fences not yet closed (still streaming)', () => {
|
||||
expect(lenientJsonParse('```json\n{"a":1, "b":2')).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('finds the first { after preamble', () => {
|
||||
expect(lenientJsonParse('Here you go:\n{"name":"x"}')).toEqual({
|
||||
name: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-closes a partial object missing its closing }', () => {
|
||||
const got = lenientJsonParse('{"name":"X","durationWeeks":4');
|
||||
expect(got).toEqual({ name: 'X', durationWeeks: 4 });
|
||||
});
|
||||
|
||||
it('auto-closes a partial array missing its closing ]', () => {
|
||||
const got = lenientJsonParse('{"weeks":[1,2,3');
|
||||
expect(got).toEqual({ weeks: [1, 2, 3] });
|
||||
});
|
||||
|
||||
it('drops a dangling property key with no value yet', () => {
|
||||
const got = lenientJsonParse('{"name":"X","notes":');
|
||||
expect(got).toEqual({ name: 'X' });
|
||||
});
|
||||
|
||||
it('drops a trailing comma after a complete value', () => {
|
||||
const got = lenientJsonParse('{"a":1,"b":2,');
|
||||
expect(got).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('handles a partial nested structure typical of AI program output', () => {
|
||||
const partial = `{
|
||||
"name": "Test",
|
||||
"type": "hypertrophy",
|
||||
"durationWeeks": 4,
|
||||
"weeks": [
|
||||
{
|
||||
"weekNumber": 1,
|
||||
"days": [
|
||||
{
|
||||
"dayOfWeek": 1,
|
||||
"name": "Push",
|
||||
"exercises": [
|
||||
{"exerciseId": "abc", "exerciseName": "Bench", "order": 0, "sets": 4
|
||||
`;
|
||||
const got = lenientJsonParse(partial) as Record<string, any>;
|
||||
expect(got).toBeTruthy();
|
||||
expect(got.name).toBe('Test');
|
||||
expect(Array.isArray(got.weeks)).toBe(true);
|
||||
expect(got.weeks[0].weekNumber).toBe(1);
|
||||
// The dangling exercise object may or may not be present
|
||||
// depending on truncation; what matters is the parser didn't
|
||||
// throw.
|
||||
});
|
||||
|
||||
it('handles an open string at the end', () => {
|
||||
const got = lenientJsonParse('{"description":"A long descrip');
|
||||
expect(got).toBeTruthy();
|
||||
expect((got as Record<string, string>).description).toMatch(
|
||||
/^A long descrip/,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for unrecoverable garbage', () => {
|
||||
// Mismatched closing brace before any opening is unrecoverable
|
||||
expect(lenientJsonParse('}}}')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findPrice, estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||
|
||||
describe('findPrice', () => {
|
||||
it('matches a known model exactly', () => {
|
||||
expect(findPrice('claude-sonnet-4-5')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('matches a known model with a date suffix (longest-prefix)', () => {
|
||||
const p = findPrice('claude-sonnet-4-5-20251022');
|
||||
expect(p?.inputPerM).toBe(3);
|
||||
expect(p?.outputPerM).toBe(15);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(findPrice('GPT-5-Mini')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null for unknown models', () => {
|
||||
expect(findPrice('mistral-medium-9000')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers longer-prefix when multiple keys match', () => {
|
||||
// claude-sonnet-4-5 is more specific than claude-sonnet-4
|
||||
const p = findPrice('claude-sonnet-4-5');
|
||||
expect(p).toEqual({ inputPerM: 3, outputPerM: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateCost', () => {
|
||||
it('returns 0 for ollama (self-hosted)', () => {
|
||||
expect(
|
||||
estimateCost({
|
||||
provider: 'ollama',
|
||||
model: 'llama3.1:8b',
|
||||
tokensIn: 1000,
|
||||
tokensOut: 500,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null for openai-compatible (unknown gateway pricing)', () => {
|
||||
expect(
|
||||
estimateCost({
|
||||
provider: 'openai-compatible',
|
||||
model: 'meta-llama/llama-3.1-8b-instruct',
|
||||
tokensIn: 1000,
|
||||
tokensOut: 500,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the model isn\'t in the price table', () => {
|
||||
expect(
|
||||
estimateCost({
|
||||
provider: 'claude',
|
||||
model: 'claude-vintage-edition',
|
||||
tokensIn: 1000,
|
||||
tokensOut: 500,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when token counts are missing', () => {
|
||||
expect(
|
||||
estimateCost({
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
tokensIn: null,
|
||||
tokensOut: 500,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('computes the right $ for a known model', () => {
|
||||
// claude-sonnet-4-5: $3/M in, $15/M out
|
||||
// 100K in + 50K out = 0.1*3 + 0.05*15 = 0.3 + 0.75 = 1.05
|
||||
const cost = estimateCost({
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
tokensIn: 100_000,
|
||||
tokensOut: 50_000,
|
||||
});
|
||||
expect(cost).toBeCloseTo(1.05, 5);
|
||||
});
|
||||
|
||||
it('computes correctly for gpt-5-nano (very cheap)', () => {
|
||||
// gpt-5-nano: $0.05/M in, $0.4/M out
|
||||
// 1000 in + 500 out = 0.001*0.05 + 0.0005*0.4 = 0.00005 + 0.0002 = 0.00025
|
||||
const cost = estimateCost({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5-nano',
|
||||
tokensIn: 1000,
|
||||
tokensOut: 500,
|
||||
});
|
||||
expect(cost).toBeCloseTo(0.00025, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCost', () => {
|
||||
it('formats null as em dash', () => {
|
||||
expect(formatCost(null)).toBe('—');
|
||||
});
|
||||
it('formats 0 as "free"', () => {
|
||||
expect(formatCost(0)).toBe('free');
|
||||
});
|
||||
it('formats sub-cent costs as "<$0.01"', () => {
|
||||
expect(formatCost(0.0023)).toBe('<$0.01');
|
||||
});
|
||||
it('formats sub-dollar costs with 3 decimal places', () => {
|
||||
expect(formatCost(0.123)).toBe('$0.123');
|
||||
});
|
||||
it('formats dollar+ costs with 2 decimal places', () => {
|
||||
expect(formatCost(2.567)).toBe('$2.57');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { v_1_0_0_6 } from './v1.0.0.6'
|
||||
import { v_1_0_0_7 } from './v1.0.0.7'
|
||||
import { v_1_1_0_1 } from './v1.1.0.1'
|
||||
import { v_1_1_0_2 } from './v1.1.0.2'
|
||||
import { v_1_1_0_3 } from './v1.1.0.3'
|
||||
|
||||
/**
|
||||
* Version graph for the `proof-of-work` package.
|
||||
@@ -25,9 +26,11 @@ import { v_1_1_0_2 } from './v1.1.0.2'
|
||||
* v1.1.0:1 — Programs UI (manual create / save / follow).
|
||||
* v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI /
|
||||
* OpenAI-compatible / Gemini / Ollama).
|
||||
* v1.1.0:3 — AI upgrades: history-as-context, test connection,
|
||||
* cost estimator, streaming preview render.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_1_0_2,
|
||||
current: v_1_1_0_3,
|
||||
other: [
|
||||
v_1_0_0_1,
|
||||
v_1_0_0_2,
|
||||
@@ -37,5 +40,6 @@ export const versionGraph = VersionGraph.of({
|
||||
v_1_0_0_6,
|
||||
v_1_0_0_7,
|
||||
v_1_1_0_1,
|
||||
v_1_1_0_2,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
/**
|
||||
* v1.1.0:3 — AI: workout-history context, test connection,
|
||||
* cost estimator, streaming preview render.
|
||||
*
|
||||
* History context (the killer feature)
|
||||
* - lib/ai/historyContext.ts builds a compact 90-day rollup of
|
||||
* the user's training: per-exercise frequency, recent weights,
|
||||
* estimated 1RMs (Epley), avg RPE, days-since-last, plus a
|
||||
* STAGNANT flag when the heaviest weight in the new half of
|
||||
* the window doesn't beat the old half.
|
||||
* - Generate page has an "Include my workout history as context"
|
||||
* checkbox (default on if you have ≥10 logged workouts). When
|
||||
* checked, the summary is appended to the system prompt so the
|
||||
* model can recommend things like "you've stalled bench at 245
|
||||
* for 6 weeks — try paused reps."
|
||||
* - The summary is text, ~1-3 KB even for heavy users. We
|
||||
* deliberately don't ship raw set logs (privacy + token cost).
|
||||
*
|
||||
* Test connection
|
||||
* - POST /api/ai/test sends a tiny "say hi in 3 words" prompt
|
||||
* against the user's configured provider and reports
|
||||
* latency + first sample of the response, or the error inline.
|
||||
* - "Test connection" button next to "Save AI config" in
|
||||
* Settings → AI integration. Lets you verify the provider/
|
||||
* model/key/baseUrl combo without going through full program
|
||||
* generation.
|
||||
*
|
||||
* Cost estimator
|
||||
* - lib/ai/pricing.ts ships a price table for the major models
|
||||
* (Claude Sonnet/Opus/Haiku 4-5, GPT-5/4o/o3, Gemini 2.5/2.0).
|
||||
* Ollama always returns 0 (self-hosted, no per-token cost).
|
||||
* openai-compatible returns null (we don't know the gateway's
|
||||
* pricing).
|
||||
* - Generation history shows per-row cost + a 30-day rolling
|
||||
* total at the top of the page.
|
||||
*
|
||||
* Streaming preview render
|
||||
* - lib/ai/lenientJson.ts: a 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.
|
||||
*
|
||||
* No schema changes. /data is untouched.
|
||||
*/
|
||||
export const v_1_1_0_3 = VersionInfo.of({
|
||||
version: '1.1.0:3',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'AI program generation gets four upgrades: (1) include your last 90 days of workout history as context — the model designs around your actual frequency, current weights, and stagnations; (2) "Test connection" button in Settings to verify provider/model/key without a full generation; (3) per-generation USD cost + 30-day rolling total in the history page (Ollama is free, openai-compatible gateways are unknown); (4) streaming preview renders the program tree as the model writes it instead of waiting for the full response. No data migration.',
|
||||
},
|
||||
migrations: {
|
||||
up: async () => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user