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.
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
'use client';
|
|
|
|
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;
|
|
templateName: string | null;
|
|
userInput: string;
|
|
provider: string;
|
|
model: string;
|
|
tokensIn: number | null;
|
|
tokensOut: number | null;
|
|
status: string;
|
|
errorMessage: string | null;
|
|
appliedProgramId: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export default function HistoryList({
|
|
initialRows,
|
|
}: {
|
|
initialRows: Row[];
|
|
}) {
|
|
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;
|
|
setBusyId(id);
|
|
const r = await fetch(`/api/ai/generations/${id}`, { method: 'DELETE' });
|
|
setBusyId(null);
|
|
if (r.ok) setRows((rs) => rs.filter((x) => x.id !== id));
|
|
else alert((await r.json().catch(() => ({}))).error ?? 'Delete failed');
|
|
};
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<p className="text-center text-sm text-zinc-500 py-12">
|
|
No AI generations yet.{' '}
|
|
<Link href="/main/ai/generate" className="text-white underline">
|
|
Generate your first program →
|
|
</Link>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
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">
|
|
{rowsWithCost.map((r) => (
|
|
<li
|
|
key={r.id}
|
|
className="bg-zinc-900 border border-zinc-800 rounded p-4"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider">
|
|
<StatusBadge status={r.status} />
|
|
<span>{new Date(r.createdAt).toLocaleString()}</span>
|
|
<span className="text-zinc-600">·</span>
|
|
<span>
|
|
{r.provider} · {r.model}
|
|
</span>
|
|
{r.tokensIn != null && (
|
|
<>
|
|
<span className="text-zinc-600">·</span>
|
|
<span>
|
|
{r.tokensIn} in · {r.tokensOut ?? '?'} out
|
|
</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">
|
|
Template: <span className="text-zinc-200">{r.templateName}</span>
|
|
</p>
|
|
)}
|
|
<p className="text-sm text-zinc-200 mt-2 line-clamp-3">
|
|
{r.userInput}
|
|
</p>
|
|
{r.errorMessage && (
|
|
<p className="text-xs text-red-400 mt-2">
|
|
Error: {r.errorMessage}
|
|
</p>
|
|
)}
|
|
{r.appliedProgramId && (
|
|
<Link
|
|
href={`/main/programs/${r.appliedProgramId}`}
|
|
className="inline-block text-xs text-emerald-400 underline mt-2"
|
|
>
|
|
View applied program →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDelete(r.id)}
|
|
disabled={busyId === r.id}
|
|
className="p-1.5 text-red-400 hover:text-red-300 disabled:opacity-50"
|
|
title="Delete this generation"
|
|
>
|
|
{busyId === r.id ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const map: Record<string, { color: string; icon: typeof CheckCircle2 }> = {
|
|
pending: { color: 'text-zinc-400', icon: Loader2 },
|
|
completed: { color: 'text-emerald-400', icon: CheckCircle2 },
|
|
applied: { color: 'text-emerald-400', icon: CheckCircle2 },
|
|
failed: { color: 'text-red-400', icon: AlertCircle },
|
|
};
|
|
const { color, icon: Icon } = map[status] ?? map.pending;
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 ${color}`}>
|
|
<Icon className="w-3 h-3" />
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|