8f149d35ab
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
9.8 KiB
TypeScript
291 lines
9.8 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
const PROVIDERS = [
|
|
{ id: 'claude', label: 'Anthropic Claude', requiresKey: true, requiresUrl: false, modelHint: 'claude-sonnet-4-5 / claude-opus-4-5' },
|
|
{ id: 'openai', label: 'OpenAI', requiresKey: true, requiresUrl: false, modelHint: 'gpt-5 / gpt-5-mini' },
|
|
{ id: 'openai-compatible', label: 'OpenAI-compatible (custom URL)', requiresKey: true, requiresUrl: true, modelHint: 'whatever your gateway exposes' },
|
|
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false, modelHint: 'gemini-2.0-flash / gemini-2.5-pro' },
|
|
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true, modelHint: 'llama3.1:8b / qwen2.5:14b' },
|
|
] as const;
|
|
|
|
interface Config {
|
|
aiProvider: string | null;
|
|
aiModel: string | null;
|
|
aiBaseUrl: string | null;
|
|
aiKeyConfigured: boolean;
|
|
}
|
|
|
|
export default function AIIntegration() {
|
|
const [cfg, setCfg] = useState<Config | null>(null);
|
|
const [provider, setProvider] = useState<string>('');
|
|
const [model, setModel] = useState('');
|
|
const [baseUrl, setBaseUrl] = useState('');
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [showKey, setShowKey] = useState(false);
|
|
const [keyDirty, setKeyDirty] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
const [testResult, setTestResult] = useState<
|
|
| null
|
|
| {
|
|
ok: true;
|
|
sample: string;
|
|
tokensIn?: number;
|
|
tokensOut?: number;
|
|
ms: number;
|
|
}
|
|
| { ok: false; error: string; ms?: number }
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/ai/config')
|
|
.then((r) => r.json())
|
|
.then((c) => {
|
|
setCfg(c);
|
|
setProvider(c.aiProvider ?? '');
|
|
setModel(c.aiModel ?? '');
|
|
setBaseUrl(c.aiBaseUrl ?? '');
|
|
})
|
|
.catch(() => setError('Failed to load AI config.'));
|
|
}, []);
|
|
|
|
const meta = PROVIDERS.find((p) => p.id === provider);
|
|
|
|
const handleTest = async () => {
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const res = await fetch('/api/ai/test', { method: 'POST' });
|
|
const body = await res.json();
|
|
setTestResult(body);
|
|
} catch (e) {
|
|
setTestResult({ ok: false, error: (e as Error).message });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
setError(null);
|
|
setSuccess(false);
|
|
try {
|
|
const body: Record<string, string | null> = {
|
|
aiProvider: provider || null,
|
|
aiModel: model || null,
|
|
aiBaseUrl: baseUrl || null,
|
|
};
|
|
// Only send apiKey if it was changed (avoids stomping a stored key
|
|
// when the user just edits the model name).
|
|
if (keyDirty) body.aiApiKey = apiKey || null;
|
|
|
|
const res = await fetch('/api/ai/config', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const b = await res.json().catch(() => ({}));
|
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
|
}
|
|
setSuccess(true);
|
|
setKeyDirty(false);
|
|
setApiKey('');
|
|
// Refresh the "configured" indicator
|
|
const c = await (await fetch('/api/ai/config')).json();
|
|
setCfg(c);
|
|
setTimeout(() => setSuccess(false), 4000);
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-4">
|
|
<header>
|
|
<h2 className="text-lg font-bold text-white">AI integration</h2>
|
|
<p className="text-sm text-zinc-500 mt-1">
|
|
Connect a model to generate training programs from natural-language
|
|
prompts. Pick a provider, enter a model + key, and the{' '}
|
|
<span className="text-zinc-300">AI → Generate</span> page will use
|
|
it. Self-hosted Ollama running on your StartOS host needs no key —
|
|
just point Base URL at it (e.g.{' '}
|
|
<code className="text-zinc-400">http://ollama.embassy:11434</code>).
|
|
</p>
|
|
</header>
|
|
|
|
<div className="space-y-4">
|
|
<Field label="Provider">
|
|
<select
|
|
value={provider}
|
|
onChange={(e) => setProvider(e.target.value)}
|
|
className={inputClass}
|
|
>
|
|
<option value="">— Disabled (no AI) —</option>
|
|
{PROVIDERS.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
{provider && (
|
|
<>
|
|
<Field label="Model">
|
|
<input
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
placeholder={meta?.modelHint ?? ''}
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
|
|
{meta?.requiresUrl && (
|
|
<Field label="Base URL">
|
|
<input
|
|
value={baseUrl}
|
|
onChange={(e) => setBaseUrl(e.target.value)}
|
|
placeholder={
|
|
meta.id === 'ollama'
|
|
? 'http://ollama.embassy:11434'
|
|
: 'https://your-gateway.example.com/v1'
|
|
}
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{meta?.requiresKey && (
|
|
<Field
|
|
label={
|
|
cfg?.aiKeyConfigured && !keyDirty
|
|
? 'API key (configured — leave blank to keep)'
|
|
: 'API key'
|
|
}
|
|
>
|
|
<div className="relative">
|
|
<input
|
|
type={showKey ? 'text' : 'password'}
|
|
value={apiKey}
|
|
onChange={(e) => {
|
|
setApiKey(e.target.value);
|
|
setKeyDirty(true);
|
|
}}
|
|
placeholder={
|
|
cfg?.aiKeyConfigured && !keyDirty ? '••••••••' : 'sk-...'
|
|
}
|
|
className={`${inputClass} pr-12`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowKey(!showKey)}
|
|
className="absolute right-3 top-2 text-xs text-zinc-500 hover:text-zinc-300"
|
|
>
|
|
{showKey ? 'hide' : 'show'}
|
|
</button>
|
|
</div>
|
|
<p className="text-[11px] text-zinc-500 mt-1">
|
|
Stored plaintext in /data/app.db. Kept inside your StartOS
|
|
host; never sent anywhere except the provider you pick.
|
|
</p>
|
|
</Field>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
|
|
Saved.
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving || testing}
|
|
className="px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save AI config'
|
|
)}
|
|
</button>
|
|
{provider && cfg?.aiProvider === provider && cfg?.aiModel && (
|
|
<button
|
|
type="button"
|
|
onClick={handleTest}
|
|
disabled={saving || testing}
|
|
className="px-4 py-2 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs uppercase tracking-wider disabled:opacity-50"
|
|
title="Send a tiny prompt to verify the configured provider responds"
|
|
>
|
|
{testing ? (
|
|
<>
|
|
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-2" />
|
|
Testing...
|
|
</>
|
|
) : (
|
|
'Test connection'
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{testResult && (
|
|
<div
|
|
className={`rounded px-3 py-2 border text-xs ${
|
|
testResult.ok
|
|
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
|
|
: 'bg-red-900/50 border-red-800 text-red-400'
|
|
}`}
|
|
>
|
|
{testResult.ok ? (
|
|
<>
|
|
✓ Connected in {testResult.ms}ms
|
|
{testResult.tokensIn != null &&
|
|
` · ${testResult.tokensIn} in / ${testResult.tokensOut ?? '?'} out tokens`}
|
|
<div className="mt-1 text-zinc-400">
|
|
Sample reply: <span className="text-zinc-200">{testResult.sample}</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>✗ {testResult.error}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const inputClass =
|
|
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
|
{label}
|
|
</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|