5e291203a5
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>
196 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|