Files
proof-of-work/proof-of-work/components/ai/GenerationDetail.tsx
T
Keysat 7a62690a4a 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.
2026-05-11 08:09:01 -05:00

631 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Loader2 } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface AIExercise {
exerciseId: string | null;
exerciseName: string;
order: number;
sets?: number | null;
repsMin?: number | null;
repsMax?: number | null;
rpe?: number | null;
restSeconds?: number | null;
suggestedWeight?: number | null;
suggestedWeightUnit?: 'lbs' | 'kg' | null;
notes?: string | null;
}
interface AIDay {
dayOfWeek: number;
name?: string | null;
description?: string | null;
exercises: AIExercise[];
}
interface AIWeek {
weekNumber: number;
phase?: string | null;
description?: string | null;
days: AIDay[];
}
interface AIProgram {
name: string;
description?: string | null;
type: string;
durationWeeks: number;
weeks: AIWeek[];
}
interface LibraryExercise {
id: string;
name: string;
type: string;
}
interface Row {
id: string;
templateName: string | null;
userInput: string;
systemPrompt: string;
userPrompt: string;
rawResponse: string | null;
parsedProgram: string | null;
progressText: string | null;
provider: string;
model: string;
tokensIn: number | null;
tokensOut: number | null;
durationMs: number | null;
status: string;
errorMessage: string | null;
appliedProgramId: string | null;
createdAt: string;
}
/**
* Client-side detail view for an AIGeneration. Three modes:
*
* - PENDING: poll for progress + render the live partial-JSON preview.
* The runner keeps writing `progressText` even if no SSE clients
* are subscribed, so polling works for cross-process resume too.
*
* - COMPLETED: render the parsed program tree with an Apply button.
* Same UI as the Generate page's preview, factored out below.
*
* - APPLIED: the user already turned this into a Program; show a
* link there. Re-applying isn't allowed (would create a duplicate).
*
* - FAILED: error message + raw response collapsed by default.
*/
export default function GenerationDetail({
row: initialRow,
exercises,
}: {
row: Row;
exercises: LibraryExercise[];
}) {
const router = useRouter();
const [row, setRow] = useState(initialRow);
// Poll while pending. 1.5s cadence — fast enough to feel live,
// gentle on the DB. Stops when status flips terminal.
useEffect(() => {
if (row.status !== 'pending') return;
let cancelled = false;
const tick = async () => {
try {
const r = await fetch(`/api/ai/generations/${row.id}`);
if (!r.ok || cancelled) return;
const fresh = await r.json();
if (cancelled) return;
setRow({
...fresh,
createdAt:
typeof fresh.createdAt === 'string'
? fresh.createdAt
: new Date(fresh.createdAt).toISOString(),
});
} catch {
/* transient — try again */
}
};
const id = setInterval(tick, 1500);
return () => {
cancelled = true;
clearInterval(id);
};
}, [row.id, row.status]);
const cost = useMemo(
() =>
estimateCost({
provider: row.provider,
model: row.model,
tokensIn: row.tokensIn,
tokensOut: row.tokensOut,
}),
[row.provider, row.model, row.tokensIn, row.tokensOut],
);
// Live partial during pending.
const partial = useMemo(
() =>
row.status === 'pending' && row.progressText
? (lenientJsonParse(row.progressText) as Partial<AIProgram> | null)
: null,
[row.status, row.progressText],
);
const parsedProgram = useMemo(
() =>
row.parsedProgram ? (JSON.parse(row.parsedProgram) as AIProgram) : null,
[row.parsedProgram],
);
return (
<div className="space-y-5">
{/* Header / metadata */}
<header className="space-y-2">
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider flex-wrap">
<StatusPill status={row.status} />
<span>{new Date(row.createdAt).toLocaleString()}</span>
<span className="text-zinc-600">·</span>
<span>
{row.provider} · {row.model}
</span>
{row.tokensIn != null && (
<>
<span className="text-zinc-600">·</span>
<span>
{row.tokensIn} in · {row.tokensOut ?? '?'} out
</span>
</>
)}
{cost != null && (
<>
<span className="text-zinc-600">·</span>
<span>{formatCost(cost)}</span>
</>
)}
{row.durationMs != null && (
<>
<span className="text-zinc-600">·</span>
<span>{formatDuration(row.durationMs)}</span>
</>
)}
</div>
{row.templateName && (
<p className="text-xs text-zinc-400">
Template: <span className="text-zinc-200">{row.templateName}</span>
</p>
)}
</header>
{/* User's prompt */}
<section className="bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2">
Your specifics
</h2>
<p className="text-sm text-zinc-200 whitespace-pre-wrap">{row.userInput}</p>
</section>
{/* Pending: live preview */}
{row.status === 'pending' && (
<section className="space-y-3">
<div className="rounded bg-blue-950/30 border border-blue-900 px-4 py-3 text-xs text-blue-200">
<p className="font-bold text-blue-100 mb-1 flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Still generating
</p>
<p>
Polling every 1.5s for progress. Safe to leave this page
the model keeps running on the server and you&apos;ll see the
result when you come back.
</p>
</div>
{partial ? (
<PartialTree partial={partial} />
) : (
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Waiting for the first parseable JSON
</div>
)}
</section>
)}
{/* Failed */}
{row.status === 'failed' && (
<section className="space-y-3">
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
{row.errorMessage ?? 'Failed.'}
</div>
<Link
href="/main/ai/generate"
className="inline-block text-xs text-zinc-400 underline hover:text-white"
>
Try again from Generate
</Link>
</section>
)}
{/* Applied — link to the program */}
{row.status === 'applied' && row.appliedProgramId && (
<section>
<Link
href={`/main/programs/${row.appliedProgramId}`}
className="inline-block px-4 py-2 rounded bg-emerald-700 text-white text-xs uppercase tracking-wider font-bold hover:bg-emerald-600"
>
View applied program
</Link>
</section>
)}
{/* Completed (not yet applied) — show preview + Apply */}
{row.status === 'completed' && parsedProgram && (
<ProgramPreview
generationId={row.id}
program={parsedProgram}
exercises={exercises}
onApplied={(programId) => router.push(`/main/programs/${programId}`)}
/>
)}
{/* Raw response + prompts (collapsed) */}
{row.rawResponse && (
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw model response</summary>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap max-h-96 overflow-auto">
{row.rawResponse}
</pre>
</details>
)}
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Exact prompts sent</summary>
<div className="mt-2 space-y-2">
<div>
<p className="font-semibold text-zinc-400 uppercase tracking-wider mb-1">
System
</p>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 whitespace-pre-wrap max-h-72 overflow-auto">
{row.systemPrompt}
</pre>
</div>
<div>
<p className="font-semibold text-zinc-400 uppercase tracking-wider mb-1">
User
</p>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 whitespace-pre-wrap max-h-72 overflow-auto">
{row.userPrompt}
</pre>
</div>
</div>
</details>
</div>
);
}
function ProgramPreview({
generationId,
program: initial,
exercises,
onApplied,
}: {
generationId: string;
program: AIProgram;
exercises: LibraryExercise[];
onApplied: (programId: string) => void;
}) {
const [program, setProgram] = useState<AIProgram>(initial);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startDate, setStartDate] = useState(
new Date().toISOString().slice(0, 10),
);
const [activate, setActivate] = useState(true);
const exerciseLookup = useMemo(
() => new Map(exercises.map((e) => [e.id, e])),
[exercises],
);
const unresolvedCount = useMemo(() => {
let n = 0;
for (const w of program.weeks)
for (const d of w.days)
for (const ex of d.exercises) {
if (!ex.exerciseId || !exerciseLookup.has(ex.exerciseId)) n++;
}
return n;
}, [program, exerciseLookup]);
const setExerciseId = (
weekIdx: number,
dayIdx: number,
exIdx: number,
newId: string | null,
) => {
setProgram((p) => {
const next = structuredClone(p);
next.weeks[weekIdx].days[dayIdx].exercises[exIdx].exerciseId = newId;
return next;
});
};
const removeExercise = (weekIdx: number, dayIdx: number, exIdx: number) => {
setProgram((p) => {
const next = structuredClone(p);
next.weeks[weekIdx].days[dayIdx].exercises.splice(exIdx, 1);
next.weeks[weekIdx].days[dayIdx].exercises.forEach(
(ex: AIExercise, i: number) => {
ex.order = i;
},
);
return next;
});
};
const handleApply = async () => {
if (unresolvedCount > 0) {
setError(
`Resolve all ${unresolvedCount} unknown exercise(s) before applying.`,
);
return;
}
setError(null);
setApplying(true);
try {
const res = await fetch('/api/ai/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
generationId,
program,
startDate,
isActive: activate,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
onApplied(body.programId);
} catch (e) {
setError((e as Error).message);
} finally {
setApplying(false);
}
};
return (
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<div>
<h3 className="text-lg font-bold text-white">{program.name}</h3>
<p className="text-xs text-zinc-500 mt-1">
{program.type} · {program.durationWeeks} week
{program.durationWeeks === 1 ? '' : 's'} · {program.weeks.length}{' '}
week{program.weeks.length === 1 ? '' : 's'} planned
</p>
{program.description && (
<p className="text-sm text-zinc-300 mt-2">{program.description}</p>
)}
</div>
{unresolvedCount > 0 && (
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
{unresolvedCount} exercise(s) the AI couldn&apos;t map to your
library. Pick a replacement or remove them before applying.
</div>
)}
<div className="space-y-3">
{program.weeks.map((w, wIdx) => (
<details
key={w.weekNumber}
open={wIdx === 0}
className="bg-zinc-950 border border-zinc-800 rounded"
>
<summary className="cursor-pointer px-3 py-2 text-sm text-white">
Week {w.weekNumber}
{w.phase && <span className="text-zinc-500"> · {w.phase}</span>}
<span className="text-zinc-600 text-xs">
{' '}
({w.days.length} day{w.days.length === 1 ? '' : 's'})
</span>
</summary>
<div className="p-3 space-y-2">
{w.days.map((d, dIdx) => (
<div
key={d.dayOfWeek}
className="bg-zinc-900 border border-zinc-800 rounded p-3"
>
<p className="text-xs font-semibold text-zinc-300 uppercase tracking-wider">
{DAY_LABELS[d.dayOfWeek]}
{d.name && (
<span className="text-zinc-500 normal-case font-normal">
{' '}
· {d.name}
</span>
)}
</p>
<ul className="mt-2 space-y-2">
{d.exercises.map((ex, eIdx) => {
const isUnknown =
!ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
const lib = ex.exerciseId
? exerciseLookup.get(ex.exerciseId)
: null;
return (
<li
key={eIdx}
className={`text-sm ${
isUnknown
? 'bg-amber-950/30 border border-amber-900'
: 'bg-zinc-950 border border-zinc-800'
} rounded p-2`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-white">
{lib?.name ?? ex.exerciseName}
{isUnknown && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
not in library
</span>
)}
</div>
{(ex.sets || ex.repsMin || ex.repsMax || ex.rpe || ex.restSeconds || ex.suggestedWeight) && (
<div className="text-xs text-zinc-500 mt-0.5">
{ex.sets ? `${ex.sets}×` : ''}
{ex.repsMin === ex.repsMax || !ex.repsMax
? (ex.repsMin ?? '?')
: `${ex.repsMin}-${ex.repsMax}`}
{ex.suggestedWeight != null && (
<> @ {ex.suggestedWeight}{ex.suggestedWeightUnit ?? ''}</>
)}
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
{ex.restSeconds ? ` · rest ${ex.restSeconds}s` : ''}
</div>
)}
{ex.notes && (
<div className="text-xs text-zinc-400 mt-1 italic">
{ex.notes}
</div>
)}
</div>
<button
type="button"
onClick={() => removeExercise(wIdx, dIdx, eIdx)}
className="text-xs text-red-400 hover:text-red-300 px-1"
title="Remove from program"
>
</button>
</div>
{isUnknown && (
<div className="mt-2">
<select
value={ex.exerciseId ?? ''}
onChange={(e) =>
setExerciseId(wIdx, dIdx, eIdx, e.target.value || null)
}
className="w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
>
<option value="">
Map to existing exercise
</option>
{exercises.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.name} ({opt.type})
</option>
))}
</select>
</div>
)}
</li>
);
})}
</ul>
</div>
))}
</div>
</details>
))}
</div>
<div className="border-t border-zinc-800 pt-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
Start date
</span>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white"
/>
</label>
<label className="flex items-end gap-2">
<input
type="checkbox"
checked={activate}
onChange={(e) => setActivate(e.target.checked)}
className="mb-2"
/>
<span className="text-xs text-zinc-300 mb-2">
Activate this program after applying
</span>
</label>
</div>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
<button
type="button"
onClick={handleApply}
disabled={applying || unresolvedCount > 0}
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
>
{applying ? (
<>
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
Applying
</>
) : (
'Apply this program'
)}
</button>
</div>
</div>
);
}
function PartialTree({ 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="text-xs">
{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>
)}
</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>
);
}
function StatusPill({ status }: { status: string }) {
const map: Record<string, { color: string; label: string }> = {
pending: { color: 'text-zinc-400 bg-zinc-800', label: 'pending' },
completed: { color: 'text-emerald-400 bg-emerald-950', label: 'completed' },
applied: { color: 'text-emerald-400 bg-emerald-950', label: 'applied' },
failed: { color: 'text-red-400 bg-red-950', label: 'failed' },
};
const m = map[status] ?? map.pending;
return (
<span
className={`inline-flex items-center gap-1 ${m.color} rounded px-2 py-0.5 text-[10px]`}
>
{m.label}
</span>
);
}
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`;
}