v1.1.0:2 — model-agnostic AI program generation (5 providers)
Five providers behind one streaming abstraction:
- claude (Anthropic)
- openai (api.openai.com)
- openai-compatible (any base URL — OpenRouter / LiteLLM /
vLLM / Together / your own gateway)
- gemini (Google)
- ollama (self-hosted; no key; LAN URL like
http://ollama.embassy:11434)
The "self-hosted Ollama on Start9" angle is the killer use case —
configure Settings → AI integration with the LAN URL of your Ollama
service and no API keys ever leave your network.
Architecture
- lib/ai/types.ts LLMProvider streaming interface
- lib/ai/sse.ts shared SSE + NDJSON line iterators
- lib/ai/providers/*.ts 5 implementations + factory
- lib/ai/programSchema.ts Zod schema + JSON-schema-for-prompt +
parseAIProgram with markdown-fence
stripping and balanced-brace JSON
extraction
- lib/ai/apply.ts materializes parsed AIProgram into
Program tree (validates exerciseIds,
rejects unresolved nulls, atomic
transaction, sets aiGenerated=true)
Schema
- UserPreferences gets aiProvider/aiModel/aiBaseUrl/aiApiKey
(plaintext — same threat model as the rest of /data). Dead
enableClaudeAI/claudeApiKey columns from v1.0.0:1-7 stay as
no-op fields.
- AIPromptTemplate (userId nullable; userId=NULL = built-in)
- AIGeneration (raw response + parsed program + status +
appliedProgramId + token counts)
- All compat-ALTER'd in docker_entrypoint.sh on first boot.
API
- POST /api/ai/generate SSE streaming: emits
generation/text/usage/complete
events; persists AIGeneration
row up front so failures show
in history too
- POST /api/ai/apply takes user-edited AIProgram,
creates Program, marks
generation as applied
- GET /api/ai/templates built-ins + this user's own
- POST /api/ai/templates create user-owned template
- PATCH /api/ai/templates/[id] edit; built-ins admin-only
- DELETE /api/ai/templates/[id] delete; built-ins admin-only
- GET /api/ai/generations list (paginated)
- GET /api/ai/generations/[id] full row
- DELETE /api/ai/generations/[id] delete one (Program survives)
- GET /api/ai/config returns aiKeyConfigured flag,
never plaintext key
- POST /api/ai/config update provider config
- DELETE /api/admin/ai/generations admin-only "clear all" with
optional userId / olderThanDays
UI
- Settings → AI integration provider/model/URL/key form;
plaintext key warning visible
- /main/ai hub page with cards
- /main/ai/generate template picker + textarea +
live SSE stream + cancel +
ProgramPreview with inline
unknown-exercise resolver +
apply button + redirect to
the new Program
- /main/ai/templates list + create + edit + delete;
per-row "show prompt" expand;
built-in delete warns about
reconcile re-creation
- /main/ai/history list + delete; status badges;
link to applied Program
- Nav: "AI" entry between Programs and Exercises (Sparkles icon)
Built-in templates
- prisma/aiTemplates.seed.json: 5 starter templates (hypertrophy /
strength / endurance / recovery / custom)
- prisma/ensurePromptTemplates.cjs: per-boot reconcile,
INSERT-or-UPDATE keyed on (userId IS NULL AND name=...);
user-created templates never touched
Tests
- tests/ai-programSchema.test.ts: extractJson + parseAIProgram
edge cases (markdown fences, balanced braces, malformed JSON,
Zod shape rejection, unresolved-exerciseId tolerance)
- tests/ai-apply.test.ts: materializes valid AIProgram, rejects
cross-user exerciseIds, rejects unresolved exercises, honors
isActive flag
- tests/routes-ai-templates.test.ts: built-in vs user permissions,
cross-user template isolation, /api/ai/config plaintext-key safety,
provider enum validation
- 123 tests across 14 files, all passing.
No data migration. Existing /data is augmented with the new columns
+ tables only.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getProvider } from '@/lib/ai/providers';
|
||||
import {
|
||||
PROGRAM_OUTPUT_SHAPE,
|
||||
parseAIProgram,
|
||||
} from '@/lib/ai/programSchema';
|
||||
|
||||
/**
|
||||
* POST /api/ai/generate
|
||||
*
|
||||
* Body: { templateId?: string, userInput: string }
|
||||
*
|
||||
* Streams the model response as Server-Sent Events:
|
||||
* event: generation data: {"id":"...generationId..."}
|
||||
* event: text data: {"delta":"..."}
|
||||
* event: usage data: {"tokensIn":N,"tokensOut":M}
|
||||
* event: complete data: {"parsedOk":true|false,"errorMessage":"..."}
|
||||
*
|
||||
* Reads the user's AI provider config from UserPreferences. The full
|
||||
* library of exercises is appended to the system prompt so the model
|
||||
* picks real exercise IDs.
|
||||
*
|
||||
* On error (no provider configured, model error, etc.) emits a single
|
||||
* `event: error` and closes.
|
||||
*
|
||||
* Always writes one AIGeneration row, regardless of success — so the
|
||||
* History page can show failed attempts too.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
templateId: z.string().optional().nullable(),
|
||||
userInput: z.string().min(1),
|
||||
});
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Invalid body',
|
||||
details: parsed.error.errors,
|
||||
}),
|
||||
{ status: 400, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
// Load the user's AI provider config.
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
||||
}),
|
||||
{ status: 400, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
const provider = getProvider(prefs.aiProvider);
|
||||
if (!provider) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Unknown provider: ${prefs.aiProvider}` }),
|
||||
{ status: 400, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
// Load the template if provided, else use a no-op default.
|
||||
let template:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
userPromptTemplate: string;
|
||||
}
|
||||
| null = null;
|
||||
if (parsed.data.templateId) {
|
||||
const t = await prisma.aIPromptTemplate.findFirst({
|
||||
where: {
|
||||
id: parsed.data.templateId,
|
||||
OR: [{ userId: user.id }, { userId: null }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
systemPrompt: true,
|
||||
userPromptTemplate: true,
|
||||
},
|
||||
});
|
||||
if (!t) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Template not found.' }),
|
||||
{ status: 404, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
template = t;
|
||||
}
|
||||
|
||||
// Load the user's exercise library to embed in the system prompt.
|
||||
const exercises = await prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
muscleGroups: true,
|
||||
},
|
||||
});
|
||||
const libraryJson = JSON.stringify(
|
||||
exercises.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
muscleGroups: (() => {
|
||||
try {
|
||||
return JSON.parse(e.muscleGroups);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
})),
|
||||
);
|
||||
|
||||
// Stitch the final system + user prompts.
|
||||
const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
||||
const systemPrompt = `${baseSystem}
|
||||
|
||||
OUTPUT SHAPE — emit ONLY a JSON object matching this shape (no commentary, no markdown fences):
|
||||
${PROGRAM_OUTPUT_SHAPE}
|
||||
|
||||
LIBRARY — pick exerciseId values from this list when possible. If you need an exercise the user doesn't have, set exerciseId to null and put the proposed name in exerciseName; the user will resolve it during preview.
|
||||
${libraryJson}`;
|
||||
|
||||
const userPromptBody =
|
||||
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
||||
parsed.data.userInput;
|
||||
|
||||
// Persist the pending row up front so the user can see it in
|
||||
// history even if the stream dies mid-flight.
|
||||
const generation = await prisma.aIGeneration.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
templateId: template?.id ?? null,
|
||||
templateName: template?.name ?? null,
|
||||
userInput: parsed.data.userInput,
|
||||
systemPrompt,
|
||||
userPrompt: userPromptBody,
|
||||
provider: provider.id,
|
||||
model: prefs.aiModel,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
// Stream the model output as SSE.
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: unknown) =>
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
send('generation', { id: generation.id });
|
||||
|
||||
let raw = '';
|
||||
let tokensIn: number | undefined;
|
||||
let tokensOut: number | undefined;
|
||||
let providerError: string | null = null;
|
||||
|
||||
try {
|
||||
for await (const chunk of provider.generate({
|
||||
apiKey: prefs.aiApiKey,
|
||||
baseUrl: prefs.aiBaseUrl,
|
||||
model: prefs.aiModel!, // validated non-null at top of POST
|
||||
systemPrompt,
|
||||
userPrompt: userPromptBody,
|
||||
signal: request.signal,
|
||||
})) {
|
||||
if (chunk.type === 'text') {
|
||||
raw += chunk.delta;
|
||||
send('text', { delta: chunk.delta });
|
||||
} else if (chunk.type === 'usage') {
|
||||
tokensIn = chunk.tokensIn;
|
||||
tokensOut = chunk.tokensOut;
|
||||
} else if (chunk.type === 'error') {
|
||||
providerError = chunk.message;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
providerError = (e as Error).message;
|
||||
}
|
||||
|
||||
// Parse + validate the assembled response.
|
||||
let parsedOk = false;
|
||||
let parseErr: string | null = null;
|
||||
let parsedJson: string | null = null;
|
||||
if (!providerError && raw) {
|
||||
const r = parseAIProgram(raw);
|
||||
if (r.ok) {
|
||||
parsedOk = true;
|
||||
parsedJson = JSON.stringify(r.program);
|
||||
} else {
|
||||
parseErr = r.reason;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the final state.
|
||||
const status = providerError
|
||||
? 'failed'
|
||||
: parsedOk
|
||||
? 'completed'
|
||||
: 'failed';
|
||||
const errorMessage =
|
||||
providerError ?? (parsedOk ? null : parseErr ?? 'Empty response');
|
||||
await prisma.aIGeneration.update({
|
||||
where: { id: generation.id },
|
||||
data: {
|
||||
rawResponse: raw || null,
|
||||
parsedProgram: parsedJson,
|
||||
tokensIn: tokensIn ?? null,
|
||||
tokensOut: tokensOut ?? null,
|
||||
status,
|
||||
errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
send('usage', { tokensIn, tokensOut });
|
||||
send('complete', { parsedOk, errorMessage });
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-store',
|
||||
'x-accel-buffering': 'no', // disable nginx buffering if proxied
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are a strength and conditioning coach. The user will describe what they want; you produce a complete training program as JSON.
|
||||
|
||||
Constraints:
|
||||
- Pick exercises from the LIBRARY below by their id. Prefer compound lifts for primary slots and accessories for the back half of each session.
|
||||
- Keep volume reasonable: 4-7 exercises per session, 60-75 minutes total.
|
||||
- Use rep ranges that match the goal: hypertrophy 6-12, strength 3-6, power 1-5.
|
||||
- For each exercise specify sets + reps (range or single) + rest in seconds. RPE is optional but useful for intensity-based programs.
|
||||
- If the user asks for something a single library exercise can't satisfy, pick the closest fit and add a coaching note explaining the variation.
|
||||
|
||||
If you cannot produce a complete program for any reason, emit a JSON object with the durationWeeks and weeks arrays best-effort and add a top-level "description" explaining the gap.`;
|
||||
Reference in New Issue
Block a user