Files
proof-of-work/proof-of-work/components/ai/HistoryList.tsx
T
Keysat dba478aa23 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.
2026-05-10 22:17:35 -05:00

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>
);
}