Files
proof-of-work/proof-of-work/components/settings/AIIntegration.tsx
T
Keysat 8f149d35ab 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:35 -05:00

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