Files
proof-of-work/proof-of-work/components/ai/HistoryList.tsx
T
Keysat 5e291203a5 v1.1.0:4 — multi-config AI, background generation, ollama auto-detect, system prompt overhaul
User-feedback-driven release after testing v1.1.0:3. Nine themes:

1. Multi-config persistence
   - New AIConfigProfile table (per-user). Save N configs, toggle one
     active. Switching providers no longer wipes the previous setup.
   - UserPreferences gains activeAIConfigId; legacy single-config
     columns are mirrored from the active profile so existing reads
     keep working without conditional logic.
   - Idempotent boot migration lifts any existing single-config row
     into a default profile.

2. Ollama auto-detect
   - The "Add config" form probes /api/tags on the StartOS internal
     addresses (ollama.startos / ollama.embassy on :11434). If
     reachable: URL pre-fills, model field becomes a dropdown of
     installed models. Fixes the copy-paste UX.

3. Curated model dropdowns for major providers
   - Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
   - OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
   - Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
   - "Other (type your own)" stays for niche models.
   - Fixes "I tried gemini-3.0-pro and got 404."

4. Background generation
   - lib/ai/generationRunner.ts: detached runner with in-memory
     pub/sub bus. POST /api/ai/generate kicks it off and returns
     immediately. SSE stream attaches by id. The runner survives
     request cancellation; navigating away no longer kills it.
   - New AIGeneration columns: progressText (in-flight stream),
     durationMs (final wall-clock).
   - Generate UI shows a banner explaining background-safety.
   - History detail page polls progress + renders partial JSON
     live for cross-process resume (page refresh, new tab).

5. System prompt overhaul
   - lib/ai/systemPromptBase.ts: structural contract prepended to
     every template. Forces JSON-only output, library-exerciseId
     usage (kills "exerciseId doesn't belong to this user" errors),
     and per-resistance-exercise suggestedWeight (with-history vs
     without-history variants).
   - aiExerciseSchema + ProgramExercise gain suggestedWeight +
     suggestedWeightUnit. Starting a workout from a ProgramDay
     pre-populates SetLog.weight from the suggestion.

6. Test connection improvements
   - Latency in seconds (was ms — confusing for slow Ollama).
   - Stale "✓ Connected" clears on form change.
   - Per-config Test (no need to activate first).
   - Generous maxOutputTokens for thinking models.
   - Gemini surfaces finishReason on empty response (e.g. "blocked
     by safety filter") instead of generic "empty response."
   - Test endpoint accepts a draft body so you can verify before
     saving + before activating.

7. History detail view
   - Click row → full program tree + exact prompts sent. Apply from
     here without re-generating. Pending rows poll for progress.

8. Sidebar sub-navigation
   - AI: Generate / History / Templates
   - Settings: General / Password / Sessions / AI integration /
     Export / Instance (admin) / Danger zone, with anchor scroll.

9. API key UX
   - "Key saved" indicator on saved configs (was confusing to see
     an empty input after a successful save).

Schema migrations (additive, idempotent in entrypoint):
  - AIConfigProfile table created
  - UserPreferences.activeAIConfigId
  - AIGeneration.progressText + durationMs
  - ProgramExercise.suggestedWeight + suggestedWeightUnit

Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:09:01 -05:00

196 lines
6.3 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;
durationMs: 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">
<Link
href={`/main/ai/history/${r.id}`}
className="min-w-0 flex-1 hover:bg-zinc-800/30 -m-2 p-2 rounded transition-colors"
>
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider flex-wrap">
<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>
</>
)}
{r.durationMs != null && (
<>
<span className="text-zinc-600">·</span>
<span title="Wall-clock generation time">
{formatDuration(r.durationMs)}
</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 && (
<span className="inline-block text-xs text-emerald-400 mt-2">
applied to a program
</span>
)}
</Link>
<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 formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}
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>
);
}