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:
Keysat
2026-05-10 15:35:35 -05:00
parent 3a5b929284
commit 974c3eb07d
36 changed files with 4206 additions and 1 deletions
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* DELETE /api/admin/ai/generations — admin-only "clear all generation
* history". Body optionally narrows:
* { userId?: string } — only that user
* { olderThanDays?: number } — purge older
* Default = wipe ALL rows.
*/
const bodySchema = z.object({
userId: z.string().optional(),
olderThanDays: z.number().int().positive().optional(),
});
export async function DELETE(request: NextRequest) {
const me = await getCurrentUser();
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!me.isAdmin)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
let body: unknown = {};
try {
body = await request.json();
} catch {
/* empty body OK */
}
const parsed = bodySchema.safeParse(body);
const filter = parsed.success ? parsed.data : {};
const where: Record<string, unknown> = {};
if (filter.userId) where.userId = filter.userId;
if (filter.olderThanDays) {
where.createdAt = {
lt: new Date(Date.now() - filter.olderThanDays * 86_400_000),
};
}
const result = await prisma.aIGeneration.deleteMany({ where });
return NextResponse.json({ deleted: result.count });
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { aiProgramSchema, type AIProgram } from '@/lib/ai/programSchema';
import { applyAIProgram } from '@/lib/ai/apply';
/**
* POST /api/ai/apply
*
* Body: { generationId: string, program: AIProgram, startDate?: string,
* isActive?: boolean }
*
* Materializes the (possibly user-edited) AIProgram into a real
* Program row + nested Weeks/Days/Exercises. Updates the
* AIGeneration row to status='applied' and stores the new program id
* in appliedProgramId.
*
* The caller is the preview UI: after the user fixes any unresolved
* exercises and tweaks fields they don't like, they POST the cleaned
* AIProgram back here.
*/
const bodySchema = z.object({
generationId: z.string().min(1),
program: aiProgramSchema,
startDate: z.string().optional(),
isActive: z.boolean().optional(),
});
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
const generation = await prisma.aIGeneration.findFirst({
where: { id: parsed.data.generationId, userId: user.id },
});
if (!generation) {
return NextResponse.json(
{ error: 'Generation not found' },
{ status: 404 },
);
}
if (generation.status === 'applied') {
return NextResponse.json(
{
error: 'This generation has already been applied to a program.',
appliedProgramId: generation.appliedProgramId,
},
{ status: 409 },
);
}
const startDate = parsed.data.startDate
? new Date(parsed.data.startDate)
: new Date();
const result = await applyAIProgram(
prisma,
parsed.data.program as AIProgram,
{
userId: user.id,
startDate,
isActive: parsed.data.isActive ?? false,
},
);
await prisma.aIGeneration.update({
where: { id: generation.id },
data: {
status: 'applied',
appliedProgramId: result.programId,
// Stash the user-edited version so history reflects what was
// actually written (vs the raw model output which is also kept).
parsedProgram: JSON.stringify(parsed.data.program),
},
});
return NextResponse.json({
programId: result.programId,
weeksCreated: result.weeksCreated,
daysCreated: result.daysCreated,
exercisesCreated: result.exercisesCreated,
});
} catch (err) {
console.error('POST /api/ai/apply error:', err);
return NextResponse.json(
{ error: (err as Error).message ?? 'Internal server error' },
{ status: 500 },
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* GET /api/ai/config — read this user's AI provider config.
* API key is NOT returned in plaintext (only
* a "configured: true|false" flag) so it
* doesn't leak via Settings page reload.
* POST /api/ai/config — update. Pass null/empty to clear a field.
*/
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
});
return NextResponse.json({
aiProvider: prefs?.aiProvider ?? null,
aiModel: prefs?.aiModel ?? null,
aiBaseUrl: prefs?.aiBaseUrl ?? null,
aiKeyConfigured: !!prefs?.aiApiKey,
});
}
const bodySchema = z.object({
aiProvider: z
.enum(['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'])
.nullable()
.optional(),
aiModel: z.string().nullable().optional(),
aiBaseUrl: z.string().url().nullable().optional().or(z.literal('')),
aiApiKey: z.string().nullable().optional(),
});
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
// Empty string -> null (UI sometimes sends "")
const norm = (v: string | null | undefined) =>
v === '' || v == null ? null : v;
const data: Record<string, string | null> = {};
if (parsed.data.aiProvider !== undefined)
data.aiProvider = parsed.data.aiProvider ?? null;
if (parsed.data.aiModel !== undefined) data.aiModel = norm(parsed.data.aiModel);
if (parsed.data.aiBaseUrl !== undefined)
data.aiBaseUrl = norm(parsed.data.aiBaseUrl);
if (parsed.data.aiApiKey !== undefined)
data.aiApiKey = norm(parsed.data.aiApiKey);
// Make sure the prefs row exists.
await prisma.userPreferences.upsert({
where: { userId: user.id },
update: data,
create: {
userId: user.id,
theme: 'system',
defaultWeightUnit: 'lbs',
defaultRestSeconds: 90,
...data,
},
});
return NextResponse.json({ success: true });
}
+265
View File
@@ -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.`;
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* GET /api/ai/generations/[id] — full row including the raw
* model response and parsed
* program (if any). Scoped to
* the actor.
* DELETE /api/ai/generations/[id] — remove a single generation row.
* The associated Program (if any)
* stays — only the AI history
* entry goes.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const row = await prisma.aIGeneration.findFirst({
where: { id: params.id, userId: user.id },
});
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(row);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const existing = await prisma.aIGeneration.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true },
});
if (!existing)
return NextResponse.json({ error: 'Not found' }, { status: 404 });
await prisma.aIGeneration.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* GET /api/ai/generations — list this user's AI generations,
* newest first. Supports ?limit=&offset=
* (default 25 / 0).
*/
export async function GET(request: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const sp = request.nextUrl.searchParams;
const limit = Math.min(parseInt(sp.get('limit') || '25'), 100);
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
const rows = await prisma.aIGeneration.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: limit + 1,
skip: offset,
select: {
id: true,
templateName: true,
userInput: true,
provider: true,
model: true,
tokensIn: true,
tokensOut: true,
status: true,
errorMessage: true,
appliedProgramId: true,
createdAt: true,
},
});
const hasMore = rows.length > limit;
return NextResponse.json({
data: rows.slice(0, limit),
hasMore,
});
}
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* PATCH /api/ai/templates/[id] — edit. User-owned templates: any
* user. Built-in templates (userId
* IS NULL): admin only. Note that
* admin edits to built-ins do NOT
* survive the next boot's reconcile
* pass — to ship lasting changes,
* edit prisma/aiTemplates.seed.json.
* DELETE /api/ai/templates/[id] — same admin gate for built-ins.
*/
const patchSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
systemPrompt: z.string().min(1).optional(),
userPromptTemplate: z.string().min(1).optional(),
});
async function loadAndCheck(
templateId: string,
user: { id: string; isAdmin: boolean },
) {
const tpl = await prisma.aIPromptTemplate.findUnique({
where: { id: templateId },
});
if (!tpl) return { tpl: null, error: 'Template not found' as const, code: 404 };
// Ownership: user templates can only be touched by their owner;
// built-ins (userId=null) can only be touched by admins.
if (tpl.userId == null) {
if (!user.isAdmin) {
return {
tpl: null,
error:
'Built-in template — admin only. Clone-to-edit if you want a personal copy.',
code: 403 as const,
};
}
} else if (tpl.userId !== user.id) {
return { tpl: null, error: 'Not your template', code: 403 as const };
}
return { tpl, error: null, code: 200 as const };
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const user = await getCurrentUser();
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { tpl, error, code } = await loadAndCheck(params.id, user);
if (!tpl) return NextResponse.json({ error }, { status: code });
const body = await request.json().catch(() => ({}));
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
const updated = await prisma.aIPromptTemplate.update({
where: { id: params.id },
data: parsed.data,
});
return NextResponse.json(updated);
} catch (err) {
console.error('PATCH /api/ai/templates/[id] error:', err);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const user = await getCurrentUser();
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { tpl, error, code } = await loadAndCheck(params.id, user);
if (!tpl) return NextResponse.json({ error }, { status: code });
await prisma.aIPromptTemplate.delete({ where: { id: params.id } });
return NextResponse.json({ success: true });
} catch (err) {
console.error('DELETE /api/ai/templates/[id] error:', err);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* GET /api/ai/templates — built-ins (userId IS NULL) + this user's own
* POST /api/ai/templates — create a user-owned template
*/
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const templates = await prisma.aIPromptTemplate.findMany({
where: { OR: [{ userId: null }, { userId: user.id }] },
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
});
return NextResponse.json(templates);
}
const createSchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
systemPrompt: z.string().min(1),
userPromptTemplate: z.string().min(1),
});
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', details: parsed.error.errors },
{ status: 400 },
);
}
const tpl = await prisma.aIPromptTemplate.create({
data: {
userId: user.id,
name: parsed.data.name,
description: parsed.data.description ?? null,
systemPrompt: parsed.data.systemPrompt,
userPromptTemplate: parsed.data.userPromptTemplate,
isBuiltIn: false,
},
});
return NextResponse.json(tpl, { status: 201 });
} catch (err) {
console.error('POST /api/ai/templates error:', err);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
@@ -0,0 +1,82 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import GenerateClient from '@/components/ai/GenerateClient';
export const dynamic = 'force-dynamic';
export default async function GeneratePage() {
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
const [templates, exercises, prefs] = await Promise.all([
prisma.aIPromptTemplate.findMany({
where: { OR: [{ userId: null }, { userId: user.id }] },
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
select: {
id: true,
name: true,
description: true,
isBuiltIn: true,
},
}),
prisma.exercise.findMany({
where: { userId: user.id },
select: { id: true, name: true, type: true },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
}),
prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true },
}),
]);
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800">
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
<Link
href="/main/programs"
className="text-zinc-400 hover:text-white"
aria-label="Back to programs"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
AI · Generate program
</h1>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6">
{!aiConfigured ? (
<div className="bg-amber-950/30 border border-amber-900 rounded p-5 text-sm text-amber-200">
<p className="font-bold text-amber-100 mb-2">
AI is not configured.
</p>
<p>
Pick a provider, model, and (if needed) API key in{' '}
<Link
href="/main/settings"
className="underline hover:text-amber-100"
>
Settings AI integration
</Link>{' '}
before you can generate programs.
</p>
</div>
) : (
<GenerateClient
templates={templates}
exercises={exercises}
providerLabel={prefs!.aiProvider!}
modelLabel={prefs!.aiModel!}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import HistoryList from '@/components/ai/HistoryList';
export const dynamic = 'force-dynamic';
export default async function HistoryPage() {
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
const rows = await prisma.aIGeneration.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 25,
select: {
id: true,
templateName: true,
userInput: true,
provider: true,
model: true,
tokensIn: true,
tokensOut: true,
status: true,
errorMessage: true,
appliedProgramId: true,
createdAt: true,
},
});
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800">
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
<Link href="/main/ai" className="text-zinc-400 hover:text-white">
<ChevronLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
AI · Generation history
</h1>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6">
<HistoryList
initialRows={rows.map((r) => ({
...r,
createdAt: r.createdAt.toISOString(),
}))}
/>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { Sparkles, ListChecks, History } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export default async function AIIndexPage() {
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
const prefs = await prisma.userPreferences.findUnique({
where: { userId: user.id },
select: { aiProvider: true, aiModel: true },
});
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
const cards = [
{
href: '/main/ai/generate',
icon: Sparkles,
title: 'Generate program',
blurb:
'Pick a template, describe what you want, and get a full multi-week program back. Review before applying.',
disabled: !aiConfigured,
},
{
href: '/main/ai/templates',
icon: ListChecks,
title: 'Prompt templates',
blurb:
'Built-in templates ship with the app. Create + save your own for repeated use.',
},
{
href: '/main/ai/history',
icon: History,
title: 'Generation history',
blurb: 'Every prompt you sent, every response, every applied program.',
},
];
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800">
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold text-white">AI</h1>
<p className="text-xs text-zinc-500">
{aiConfigured ? (
<>
{prefs!.aiProvider} · {prefs!.aiModel}
</>
) : (
<Link href="/main/settings" className="underline">
Configure in Settings
</Link>
)}
</p>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6 grid gap-3">
{cards.map((c) => {
const Icon = c.icon;
const disabled = !!c.disabled;
return (
<Link
key={c.href}
href={disabled ? '/main/settings' : c.href}
className={`block bg-zinc-900 border border-zinc-800 rounded p-5 hover:border-zinc-700 transition ${disabled ? 'opacity-60' : ''}`}
>
<div className="flex items-start gap-3">
<Icon className="w-5 h-5 text-zinc-400 mt-0.5" />
<div>
<h2 className="text-base font-semibold text-white">
{c.title}
{disabled && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
configure first
</span>
)}
</h2>
<p className="text-sm text-zinc-500 mt-1">{c.blurb}</p>
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import TemplatesList from '@/components/ai/TemplatesList';
export const dynamic = 'force-dynamic';
export default async function TemplatesPage() {
const user = await getCurrentUser();
if (!user) redirect('/auth/login');
const templates = await prisma.aIPromptTemplate.findMany({
where: { OR: [{ userId: null }, { userId: user.id }] },
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
});
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800">
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
<Link
href="/main/ai"
className="text-zinc-400 hover:text-white"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
AI · Prompt templates
</h1>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6">
<TemplatesList
initialTemplates={templates.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
systemPrompt: t.systemPrompt,
userPromptTemplate: t.userPromptTemplate,
isBuiltIn: t.isBuiltIn,
isMine: t.userId === user.id,
}))}
isAdmin={user.isAdmin}
/>
</div>
</div>
);
}
+2
View File
@@ -6,6 +6,7 @@ import {
Dumbbell, Dumbbell,
ListChecks, ListChecks,
Calendar, Calendar,
Sparkles,
Settings, Settings,
LogOut, LogOut,
} from 'lucide-react'; } from 'lucide-react';
@@ -19,6 +20,7 @@ const navLinks = [
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell }, { href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
{ href: '/main/programs', label: 'Programs', icon: Calendar }, { href: '/main/programs', label: 'Programs', icon: Calendar },
{ href: '/main/ai', label: 'AI', icon: Sparkles },
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks }, { href: '/main/exercises', label: 'Exercises', icon: ListChecks },
{ href: '/main/settings', label: 'Settings', icon: Settings }, { href: '/main/settings', label: 'Settings', icon: Settings },
]; ];
+2
View File
@@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/auth";
import SettingsForm from "@/components/settings/SettingsForm"; import SettingsForm from "@/components/settings/SettingsForm";
import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
import SessionsList from "@/components/settings/SessionsList"; import SessionsList from "@/components/settings/SessionsList";
import AIIntegration from "@/components/settings/AIIntegration";
import ExportMyData from "@/components/settings/ExportMyData"; import ExportMyData from "@/components/settings/ExportMyData";
import DangerZone from "@/components/settings/DangerZone"; import DangerZone from "@/components/settings/DangerZone";
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
@@ -32,6 +33,7 @@ export default async function SettingsPage() {
<SettingsForm user={user} /> <SettingsForm user={user} />
<ChangePasswordForm /> <ChangePasswordForm />
<SessionsList /> <SessionsList />
<AIIntegration />
<ExportMyData /> <ExportMyData />
{user.isAdmin && instanceSettings && ( {user.isAdmin && instanceSettings && (
<AdminInstanceSettings <AdminInstanceSettings
@@ -0,0 +1,615 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Sparkles, Square } from 'lucide-react';
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface Template {
id: string;
name: string;
description: string | null;
isBuiltIn: boolean;
}
interface LibraryExercise {
id: string;
name: string;
type: string;
}
// AI output shape (matches lib/ai/programSchema.ts)
interface AIExercise {
exerciseId: string | null;
exerciseName: string;
order: number;
sets?: number | null;
repsMin?: number | null;
repsMax?: number | null;
rpe?: number | null;
restSeconds?: number | 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[];
}
type Phase =
| { kind: 'idle' }
| { kind: 'streaming'; raw: string }
| { kind: 'parsed'; raw: string; program: AIProgram }
| { kind: 'failed'; raw: string; message: string };
export default function GenerateClient({
templates,
exercises,
providerLabel,
modelLabel,
}: {
templates: Template[];
exercises: LibraryExercise[];
providerLabel: string;
modelLabel: string;
}) {
const router = useRouter();
const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
const [userInput, setUserInput] = useState('');
const [generationId, setGenerationId] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
const [tokens, setTokens] = useState<{ in?: number; out?: number }>({});
const abortRef = useRef<AbortController | null>(null);
const selectedTemplate = useMemo(
() => templates.find((t) => t.id === templateId),
[templates, templateId],
);
const handleGenerate = async () => {
if (!userInput.trim()) return;
setPhase({ kind: 'streaming', raw: '' });
setGenerationId(null);
setTokens({});
abortRef.current = new AbortController();
let raw = '';
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
templateId: templateId || null,
userInput,
}),
signal: abortRef.current.signal,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setPhase({
kind: 'failed',
raw: '',
message: body.error ?? `HTTP ${res.status}`,
});
return;
}
if (!res.body) {
setPhase({ kind: 'failed', raw: '', message: 'No response body.' });
return;
}
// Parse SSE stream
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let done = false;
while (!done) {
const { value, done: d } = await reader.read();
if (d) {
done = true;
break;
}
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) >= 0) {
const event = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let evtName = 'message';
const dataLines: string[] = [];
for (const line of event.split('\n')) {
if (line.startsWith('event:')) evtName = line.slice(6).trim();
else if (line.startsWith('data:'))
dataLines.push(line.slice(5).trimStart());
}
if (!dataLines.length) continue;
const data = dataLines.join('\n');
let parsed: any;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
if (evtName === 'generation') {
setGenerationId(parsed.id);
} else if (evtName === 'text') {
raw += parsed.delta;
setPhase({ kind: 'streaming', raw });
} else if (evtName === 'usage') {
setTokens({ in: parsed.tokensIn, out: parsed.tokensOut });
} else if (evtName === 'complete') {
// Server already validated/stored the parsed program. We
// fetch the generation record AFTER the stream closes
// (below) to get the parsed JSON. Just record the
// success/failure outcome here; if it failed, render
// the error inline now since we're not going to fetch.
if (!parsed.parsedOk) {
setPhase({
kind: 'failed',
raw,
message: parsed.errorMessage ?? 'Failed to parse model output.',
});
}
}
}
}
} catch (e) {
if ((e as Error).name === 'AbortError') {
setPhase({ kind: 'failed', raw, message: 'Cancelled.' });
} else {
setPhase({
kind: 'failed',
raw,
message: (e as Error).message,
});
}
return;
}
// After stream closes, fetch the generation row to get the parsed
// program (we don't try to re-parse client-side — server already did).
const id = generationIdRef.current;
if (id) {
const r = await fetch(`/api/ai/generations/${id}`);
if (r.ok) {
const gen = await r.json();
if (gen.status === 'completed' && gen.parsedProgram) {
setPhase({
kind: 'parsed',
raw,
program: JSON.parse(gen.parsedProgram) as AIProgram,
});
return;
}
if (gen.status === 'failed') {
setPhase({
kind: 'failed',
raw,
message: gen.errorMessage ?? 'Failed.',
});
return;
}
}
}
};
// Capture the generationId in a ref so the async fetch after the
// stream has access to it (the closure above sees the initial null).
const generationIdRef = useRef<string | null>(null);
useEffect(() => {
generationIdRef.current = generationId;
}, [generationId]);
const handleCancel = () => {
abortRef.current?.abort();
};
return (
<div className="space-y-6">
<div className="text-xs text-zinc-500 uppercase tracking-wider">
Provider: <span className="text-zinc-300">{providerLabel}</span>
{' · '}Model: <span className="text-zinc-300">{modelLabel}</span>
</div>
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
<Field label="Template">
<select
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
className={inputClass}
disabled={phase.kind === 'streaming'}
>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.isBuiltIn ? '★ ' : ''}
{t.name}
</option>
))}
</select>
{selectedTemplate?.description && (
<p className="text-xs text-zinc-500 mt-1">
{selectedTemplate.description}
</p>
)}
</Field>
<Field label="Your specifics">
<textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder="e.g. 8 weeks, 4 days per week, heavy leg emphasis. I have a meet in 6 weeks. Bench Press is at 245x5, Squat 365x3, Deadlift 425x3."
rows={6}
className={inputClass}
disabled={phase.kind === 'streaming'}
/>
</Field>
<div className="flex items-center gap-2">
{phase.kind === 'streaming' ? (
<button
type="button"
onClick={handleCancel}
className="inline-flex items-center gap-2 px-4 py-2 rounded border border-red-900 text-red-400 text-xs uppercase tracking-wider hover:bg-red-900/30"
>
<Square className="w-3.5 h-3.5" />
Cancel
</button>
) : (
<button
type="button"
onClick={handleGenerate}
disabled={!userInput.trim()}
className="inline-flex items-center gap-2 px-5 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"
>
<Sparkles className="w-4 h-4" />
Generate
</button>
)}
</div>
</section>
{(phase.kind === 'streaming' || phase.kind === 'failed' || phase.kind === 'parsed') && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
{phase.kind === 'streaming' ? 'Generating...' : 'Response'}
</h2>
{(tokens.in != null || tokens.out != null) && (
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
{tokens.in ?? '?'} in · {tokens.out ?? '?'} out
</span>
)}
</div>
{phase.kind === 'streaming' && (
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap">
{phase.raw || '(waiting for first token...)'}
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
</div>
)}
{phase.kind === 'failed' && (
<>
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
{phase.message}
</div>
{phase.raw && (
<details className="text-xs text-zinc-500">
<summary className="cursor-pointer">Raw response</summary>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap">
{phase.raw}
</pre>
</details>
)}
</>
)}
{phase.kind === 'parsed' && generationId && (
<ProgramPreview
program={phase.program}
generationId={generationId}
exercises={exercises}
onApplied={(programId) => {
router.push(`/main/programs/${programId}`);
}}
/>
)}
</section>
)}
</div>
);
}
function ProgramPreview({
program: initial,
generationId,
exercises,
onApplied,
}: {
program: AIProgram;
generationId: string;
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) n++;
return n;
}, [program]);
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);
// Renumber order
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;
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) && (
<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.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=""
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">
<Field label="Start date">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className={inputClass}
/>
</Field>
<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>
);
}
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>
);
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
interface Row {
id: string;
templateName: string | null;
userInput: string;
provider: string;
model: string;
tokensIn: number | null;
tokensOut: 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);
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 (
<ul className="space-y-3">
{rows.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">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider">
<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>
</>
)}
</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 && (
<Link
href={`/main/programs/${r.appliedProgramId}`}
className="inline-block text-xs text-emerald-400 underline mt-2"
>
View applied program
</Link>
)}
</div>
<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 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>
);
}
@@ -0,0 +1,342 @@
'use client';
import { useState } from 'react';
import { Plus, Trash2, Pencil, Loader2 } from 'lucide-react';
interface T {
id: string;
name: string;
description: string | null;
systemPrompt: string;
userPromptTemplate: string;
isBuiltIn: boolean;
isMine: boolean;
}
export default function TemplatesList({
initialTemplates,
isAdmin,
}: {
initialTemplates: T[];
isAdmin: boolean;
}) {
const [templates, setTemplates] = useState(initialTemplates);
const [editingId, setEditingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const refresh = async () => {
const r = await fetch('/api/ai/templates');
if (r.ok) {
const list = (await r.json()) as Array<T & { userId: string | null }>;
// Mark isMine on the client by checking who owns each.
// The API already returns built-ins + this user's own — we
// can distinguish them by isBuiltIn flag.
setTemplates(
list.map((t) => ({
...t,
isMine: !t.isBuiltIn,
})),
);
}
};
return (
<div className="space-y-4">
<div className="flex justify-end">
<button
type="button"
onClick={() => setCreating(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100"
>
<Plus className="w-3.5 h-3.5" />
New template
</button>
</div>
{creating && (
<TemplateEditor
mode="create"
onClose={() => setCreating(false)}
onSaved={() => {
setCreating(false);
refresh();
}}
/>
)}
<ul className="space-y-3">
{templates.map((t) => {
const editable = t.isMine || (t.isBuiltIn && isAdmin);
if (editingId === t.id) {
return (
<li key={t.id}>
<TemplateEditor
mode="edit"
template={t}
onClose={() => setEditingId(null)}
onSaved={() => {
setEditingId(null);
refresh();
}}
/>
</li>
);
}
return (
<li
key={t.id}
className="bg-zinc-900 border border-zinc-800 rounded p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-white">
{t.name}
{t.isBuiltIn && (
<span className="ml-2 text-[10px] uppercase tracking-wider bg-zinc-800 text-zinc-300 px-1.5 py-0.5 rounded font-normal">
built-in
</span>
)}
</h3>
{t.description && (
<p className="text-xs text-zinc-500 mt-1">
{t.description}
</p>
)}
</div>
<div className="flex items-center gap-1">
{editable && (
<button
type="button"
onClick={() => setEditingId(t.id)}
className="p-1.5 text-zinc-400 hover:text-white"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{editable && (
<DeleteButton
templateId={t.id}
isBuiltIn={t.isBuiltIn}
onDeleted={() => refresh()}
/>
)}
</div>
</div>
<details className="mt-2">
<summary className="cursor-pointer text-[11px] text-zinc-500 uppercase tracking-wider">
Show prompt
</summary>
<div className="mt-2 space-y-2 text-xs">
<div>
<p className="text-zinc-500 uppercase tracking-wider mb-1">
System
</p>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-2 whitespace-pre-wrap text-zinc-300">
{t.systemPrompt}
</pre>
</div>
<div>
<p className="text-zinc-500 uppercase tracking-wider mb-1">
User template
</p>
<pre className="bg-zinc-950 border border-zinc-800 rounded p-2 whitespace-pre-wrap text-zinc-300">
{t.userPromptTemplate}
</pre>
</div>
</div>
</details>
</li>
);
})}
</ul>
</div>
);
}
function DeleteButton({
templateId,
isBuiltIn,
onDeleted,
}: {
templateId: string;
isBuiltIn: boolean;
onDeleted: () => void;
}) {
const [busy, setBusy] = useState(false);
return (
<button
type="button"
disabled={busy}
onClick={async () => {
if (
!confirm(
isBuiltIn
? 'Delete this built-in template? It will be re-created from the package JSON on next boot unless you also remove it from prisma/aiTemplates.seed.json.'
: 'Delete this template?',
)
)
return;
setBusy(true);
const r = await fetch(`/api/ai/templates/${templateId}`, {
method: 'DELETE',
});
setBusy(false);
if (r.ok) onDeleted();
else {
const b = await r.json().catch(() => ({}));
alert(b.error ?? `HTTP ${r.status}`);
}
}}
className="p-1.5 text-red-400 hover:text-red-300"
title="Delete"
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
);
}
function TemplateEditor({
mode,
template,
onClose,
onSaved,
}: {
mode: 'create' | 'edit';
template?: T;
onClose: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState(template?.name ?? '');
const [description, setDescription] = useState(template?.description ?? '');
const [systemPrompt, setSystemPrompt] = useState(template?.systemPrompt ?? '');
const [userPromptTemplate, setUserPromptTemplate] = useState(
template?.userPromptTemplate ?? '{{userInput}}',
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
if (!name.trim() || !systemPrompt.trim() || !userPromptTemplate.trim()) {
setError('Name, system prompt, and user prompt template are required.');
return;
}
setBusy(true);
setError(null);
try {
const body = {
name,
description: description || null,
systemPrompt,
userPromptTemplate,
};
const url =
mode === 'create'
? '/api/ai/templates'
: `/api/ai/templates/${template!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
const r = await fetch(url, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const b = await r.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${r.status}`);
}
onSaved();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="bg-zinc-900 border border-emerald-900 rounded p-4 space-y-3">
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">
{mode === 'create' ? 'New template' : `Edit: ${template!.name}`}
</h3>
<Field label="Name">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My hypertrophy block"
className={inputClass}
/>
</Field>
<Field label="Description (optional)">
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this template is for"
className={inputClass}
/>
</Field>
<Field label="System prompt (the role / constraints sent to the model)">
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={6}
className={inputClass}
/>
</Field>
<Field label="User prompt template (use {{userInput}} for the user's specifics)">
<textarea
value={userPromptTemplate}
onChange={(e) => setUserPromptTemplate(e.target.value)}
rows={3}
className={inputClass}
/>
</Field>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleSave}
disabled={busy}
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"
>
{busy ? (
<>
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-1" />
Saving...
</>
) : (
'Save'
)}
</button>
<button
type="button"
onClick={onClose}
disabled={busy}
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"
>
Cancel
</button>
</div>
</div>
);
}
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>
);
}
@@ -0,0 +1,221 @@
'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);
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 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>
)}
<button
type="button"
onClick={handleSave}
disabled={saving}
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>
</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>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { Prisma, type PrismaClient } from '@prisma/client';
import type { AIProgram } from './programSchema';
/**
* Materialize a parsed AIProgram into a real Program row + nested
* Weeks/Days/Exercises in one transaction.
*
* The caller is responsible for validating the AIProgram (parseAIProgram
* returns it). Here we additionally:
* - verify every non-null exerciseId is in the user's library;
* reject the apply if any unknown ID is referenced (the UI must
* resolve unknowns BEFORE calling this).
* - set Program.aiGenerated = true and stash any extra notes.
*
* Returns the created Program's id.
*/
export interface ApplyOpts {
userId: string;
startDate: Date;
/**
* If true, mark Program.isActive = true. Default false (let the
* user explicitly activate after reviewing).
*/
isActive?: boolean;
}
export interface ApplyResult {
programId: string;
weeksCreated: number;
daysCreated: number;
exercisesCreated: number;
}
export async function applyAIProgram(
prisma: PrismaClient,
ai: AIProgram,
opts: ApplyOpts,
): Promise<ApplyResult> {
// Verify every exerciseId in the tree belongs to this user.
const ids = new Set<string>();
for (const w of ai.weeks)
for (const d of w.days)
for (const ex of d.exercises) {
if (ex.exerciseId) ids.add(ex.exerciseId);
}
if (ids.size > 0) {
const owned = await prisma.exercise.findMany({
where: { userId: opts.userId, id: { in: Array.from(ids) } },
select: { id: true },
});
const ownedIds = new Set(owned.map((e) => e.id));
const bad = Array.from(ids).filter((id) => !ownedIds.has(id));
if (bad.length > 0) {
throw new Error(
`Cannot apply: ${bad.length} exerciseId(s) don't belong to this user. ` +
`Resolve unknown exercises in the preview first.`,
);
}
}
// Also reject if any exercise has exerciseId === null (unresolved).
const unresolved: string[] = [];
for (const w of ai.weeks)
for (const d of w.days)
for (const ex of d.exercises) {
if (!ex.exerciseId) unresolved.push(ex.exerciseName);
}
if (unresolved.length > 0) {
throw new Error(
`Cannot apply: ${unresolved.length} exercise(s) still unresolved ` +
`(${unresolved.slice(0, 3).join(', ')}${
unresolved.length > 3 ? '...' : ''
}). Map them to library exercises or remove them in the preview.`,
);
}
let weeksCreated = 0;
let daysCreated = 0;
let exercisesCreated = 0;
const programId = await prisma.$transaction(async (tx) => {
const program = await tx.program.create({
data: {
userId: opts.userId,
name: ai.name,
description: ai.description ?? null,
type: ai.type,
durationWeeks: ai.durationWeeks,
startDate: opts.startDate,
isActive: opts.isActive ?? false,
aiGenerated: true,
},
});
for (const w of ai.weeks) {
const week = await tx.programWeek.create({
data: {
programId: program.id,
weekNumber: w.weekNumber,
phase: w.phase ?? null,
description: w.description ?? null,
},
});
weeksCreated++;
for (const d of w.days) {
const day = await tx.programDay.create({
data: {
weekId: week.id,
dayOfWeek: d.dayOfWeek,
name: d.name ?? null,
description: d.description ?? null,
},
});
daysCreated++;
if (d.exercises.length > 0) {
await tx.programExercise.createMany({
data: d.exercises.map((ex) => ({
dayId: day.id,
exerciseId: ex.exerciseId!, // validated non-null above
order: ex.order,
sets: ex.sets ?? null,
repsMin: ex.repsMin ?? null,
repsMax: ex.repsMax ?? null,
rpe: ex.rpe ?? null,
restSeconds: ex.restSeconds ?? null,
notes: ex.notes ?? null,
})) as Prisma.ProgramExerciseCreateManyInput[],
});
exercisesCreated += d.exercises.length;
}
}
}
return program.id;
});
return { programId, weeksCreated, daysCreated, exercisesCreated };
}
+176
View File
@@ -0,0 +1,176 @@
import { z } from 'zod';
/**
* The shape we ask LLMs to produce, validated server-side via Zod
* after parsing whatever JSON came back. Maps 1:1 onto the existing
* Program -> ProgramWeek -> ProgramDay -> ProgramExercise tables
* so the apply step is just a transactional INSERT.
*
* `exerciseId` is nullable: the model picks from the user's library
* when it can but is allowed to suggest an exercise that doesn't
* exist yet (we'll prompt the user to create it during preview).
* `exerciseName` is REQUIRED so we always have a display label and a
* fallback for fuzzy matching.
*/
export const aiExerciseSchema = z.object({
exerciseId: z.string().nullable(),
exerciseName: z.string().min(1),
order: z.number().int().nonnegative(),
sets: z.number().int().positive().optional().nullable(),
repsMin: z.number().int().positive().optional().nullable(),
repsMax: z.number().int().positive().optional().nullable(),
rpe: z.number().int().min(1).max(10).optional().nullable(),
restSeconds: z.number().int().nonnegative().optional().nullable(),
notes: z.string().optional().nullable(),
});
export const aiDaySchema = z.object({
dayOfWeek: z.number().int().min(0).max(6),
name: z.string().optional().nullable(),
description: z.string().optional().nullable(),
exercises: z.array(aiExerciseSchema),
});
export const aiWeekSchema = z.object({
weekNumber: z.number().int().positive(),
phase: z.string().optional().nullable(),
description: z.string().optional().nullable(),
days: z.array(aiDaySchema),
});
export const aiProgramSchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
type: z.string().min(1),
durationWeeks: z.number().int().positive(),
weeks: z.array(aiWeekSchema),
});
export type AIProgram = z.infer<typeof aiProgramSchema>;
export type AIWeek = z.infer<typeof aiWeekSchema>;
export type AIDay = z.infer<typeof aiDaySchema>;
export type AIExercise = z.infer<typeof aiExerciseSchema>;
/**
* The JSON-schema-ish description we paste into the system prompt so
* the model knows the exact shape to emit. We don't pass it to a
* provider's "structured output" mode (Ollama in particular doesn't
* support that uniformly across models) — it's just a doc the model
* reads.
*/
export const PROGRAM_OUTPUT_SHAPE = `{
"name": "<string>",
"description": "<string, optional>",
"type": "<string: hypertrophy | strength | power | endurance | recovery | general>",
"durationWeeks": <int >= 1>,
"weeks": [
{
"weekNumber": <int >= 1>,
"phase": "<string, optional, e.g. Volume / Intensity / Deload>",
"description": "<string, optional>",
"days": [
{
"dayOfWeek": <int 0-6, 0=Sunday>,
"name": "<string, optional, e.g. Push Day>",
"description": "<string, optional>",
"exercises": [
{
"exerciseId": "<string from the library list, or null if you need an exercise the user doesn't have>",
"exerciseName": "<string, the canonical name>",
"order": <int >= 0>,
"sets": <int, optional>,
"repsMin": <int, optional>,
"repsMax": <int, optional>,
"rpe": <int 1-10, optional>,
"restSeconds": <int >= 0, optional>,
"notes": "<string, optional, coaching note>"
}
]
}
]
}
]
}`;
/**
* Try to extract a JSON object from a model's raw response. Models
* sometimes wrap output in ```json fences or add commentary before/
* after. This pulls the first balanced {...} block.
*/
export function extractJson(raw: string): string | null {
// Strip code fences if present
const fenced = raw.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
if (fenced) return fenced[1].trim();
// Otherwise find the first balanced {...}
const start = raw.indexOf('{');
if (start < 0) return null;
let depth = 0;
let inStr = false;
let escape = false;
for (let i = start; i < raw.length; i++) {
const c = raw[i];
if (escape) {
escape = false;
continue;
}
if (c === '\\') {
escape = true;
continue;
}
if (c === '"') {
inStr = !inStr;
continue;
}
if (inStr) continue;
if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) return raw.slice(start, i + 1);
}
}
return null;
}
/**
* Parse + validate a model's raw response. Returns either a clean
* AIProgram or a structured error.
*/
export function parseAIProgram(
raw: string,
):
| { ok: true; program: AIProgram }
| { ok: false; reason: string; json?: string } {
const json = extractJson(raw);
if (!json) {
return {
ok: false,
reason: 'Could not find a JSON object in the response.',
};
}
let obj: unknown;
try {
obj = JSON.parse(json);
} catch (e) {
return {
ok: false,
reason: `JSON parse error: ${(e as Error).message}`,
json,
};
}
const result = aiProgramSchema.safeParse(obj);
if (!result.success) {
return {
ok: false,
reason:
'JSON did not match the expected shape: ' +
result.error.errors
.slice(0, 5)
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join('; '),
json,
};
}
return { ok: true, program: result.data };
}
+103
View File
@@ -0,0 +1,103 @@
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
import { sseLines } from '../sse';
/**
* Anthropic Claude: SSE over POST /v1/messages.
*
* Streaming events:
* - message_start → has usage.input_tokens
* - content_block_delta (type=text_delta) → has delta.text
* - message_delta → has usage.output_tokens
* - message_stop → terminal
* - error → fatal
*/
export const claude: LLMProvider = {
id: 'claude',
label: 'Anthropic Claude',
requiresApiKey: true,
requiresBaseUrl: false,
async *generate(opts: GenerateOpts): AsyncGenerator<GenerateChunk, void, void> {
if (!opts.apiKey) {
yield { type: 'error', message: 'Claude API key is required.' };
return;
}
const url = 'https://api.anthropic.com/v1/messages';
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': opts.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: opts.model,
max_tokens: 8000,
stream: true,
system: opts.systemPrompt,
messages: [{ role: 'user', content: opts.userPrompt }],
}),
signal: opts.signal,
});
} catch (e) {
yield {
type: 'error',
message: `Claude unreachable: ${(e as Error).message}`,
};
return;
}
if (!res.ok) {
yield {
type: 'error',
message: `Claude HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
};
return;
}
let tokensIn: number | undefined;
let tokensOut: number | undefined;
try {
for await (const data of sseLines(res)) {
if (data === '[DONE]') break;
let evt: any;
try {
evt = JSON.parse(data);
} catch {
continue;
}
switch (evt.type) {
case 'message_start':
tokensIn = evt.message?.usage?.input_tokens;
break;
case 'content_block_delta':
if (evt.delta?.type === 'text_delta' && evt.delta.text) {
yield { type: 'text', delta: evt.delta.text };
}
break;
case 'message_delta':
if (evt.usage?.output_tokens != null) {
tokensOut = evt.usage.output_tokens;
}
break;
case 'message_stop':
// terminal — fall through to outer break
break;
case 'error':
yield {
type: 'error',
message: evt.error?.message ?? 'Claude stream error',
};
return;
}
}
yield { type: 'usage', tokensIn, tokensOut };
yield { type: 'done' };
} catch (e) {
yield {
type: 'error',
message: `Claude stream error: ${(e as Error).message}`,
};
}
},
};
+89
View File
@@ -0,0 +1,89 @@
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
/**
* Google Gemini: streamed JSON over POST
* /v1beta/models/{model}:streamGenerateContent?alt=sse&key=KEY
*
* The `alt=sse` flag makes Gemini emit Server-Sent Events with each
* `data:` line a partial GenerateContentResponse chunk:
* { candidates: [ { content: { parts: [ { text: "..." } ] } } ],
* usageMetadata: { promptTokenCount, candidatesTokenCount } }
*/
export const gemini: LLMProvider = {
id: 'gemini',
label: 'Google Gemini',
requiresApiKey: true,
requiresBaseUrl: false,
async *generate(opts: GenerateOpts): AsyncGenerator<GenerateChunk, void, void> {
if (!opts.apiKey) {
yield { type: 'error', message: 'Gemini API key is required.' };
return;
}
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
opts.model,
)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(opts.apiKey)}`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: opts.systemPrompt }] },
contents: [
{ role: 'user', parts: [{ text: opts.userPrompt }] },
],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 8000,
},
}),
signal: opts.signal,
});
} catch (e) {
yield {
type: 'error',
message: `Gemini unreachable: ${(e as Error).message}`,
};
return;
}
if (!res.ok) {
yield {
type: 'error',
message: `Gemini HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
};
return;
}
let tokensIn: number | undefined;
let tokensOut: number | undefined;
try {
// Gemini SSE: same line-delimited "data: ..." frames.
const { sseLines } = await import('../sse');
for await (const data of sseLines(res)) {
let evt: any;
try {
evt = JSON.parse(data);
} catch {
continue;
}
const parts = evt.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
for (const p of parts) {
if (p.text) yield { type: 'text', delta: p.text };
}
}
if (evt.usageMetadata) {
tokensIn = evt.usageMetadata.promptTokenCount;
tokensOut = evt.usageMetadata.candidatesTokenCount;
}
}
yield { type: 'usage', tokensIn, tokensOut };
yield { type: 'done' };
} catch (e) {
yield {
type: 'error',
message: `Gemini stream error: ${(e as Error).message}`,
};
}
},
};
+28
View File
@@ -0,0 +1,28 @@
import type { LLMProvider, ProviderId } from '../types';
import { ollama } from './ollama';
import { claude } from './claude';
import { openai, openaiCompatible } from './openai';
import { gemini } from './gemini';
const ALL: Record<ProviderId, LLMProvider> = {
claude,
openai,
'openai-compatible': openaiCompatible,
gemini,
ollama,
};
export function getProvider(id: string): LLMProvider | null {
return (ALL as Record<string, LLMProvider | undefined>)[id] ?? null;
}
/** Stable list for UI dropdowns. Order matches the Settings select. */
export const PROVIDER_ORDER: ProviderId[] = [
'claude',
'openai',
'openai-compatible',
'gemini',
'ollama',
];
export const PROVIDERS = ALL;
+83
View File
@@ -0,0 +1,83 @@
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
import { ndjsonLines } from '../sse';
/**
* Ollama: streaming NDJSON over POST /api/chat.
*
* No API key. baseUrl required (e.g. http://ollama.embassy:11434).
* Each line is `{"message":{"content":"..."},"done":false}` until
* `{"done":true,"prompt_eval_count":N,"eval_count":M}`.
*/
export const ollama: LLMProvider = {
id: 'ollama',
label: 'Ollama (self-hosted)',
requiresApiKey: false,
requiresBaseUrl: true,
async *generate(opts: GenerateOpts): AsyncGenerator<GenerateChunk, void, void> {
if (!opts.baseUrl) {
yield { type: 'error', message: 'Ollama base URL is required.' };
return;
}
const url = opts.baseUrl.replace(/\/$/, '') + '/api/chat';
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: opts.model,
stream: true,
messages: [
{ role: 'system', content: opts.systemPrompt },
{ role: 'user', content: opts.userPrompt },
],
options: {
temperature: 0.7,
},
}),
signal: opts.signal,
});
} catch (e) {
yield {
type: 'error',
message: `Ollama unreachable at ${opts.baseUrl}: ${(e as Error).message}`,
};
return;
}
if (!res.ok) {
yield {
type: 'error',
message: `Ollama HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
};
return;
}
let tokensIn: number | undefined;
let tokensOut: number | undefined;
try {
for await (const line of ndjsonLines(res)) {
let evt: any;
try {
evt = JSON.parse(line);
} catch {
continue;
}
if (evt.message?.content) {
yield { type: 'text', delta: evt.message.content };
}
if (evt.done) {
tokensIn = evt.prompt_eval_count;
tokensOut = evt.eval_count;
break;
}
}
yield { type: 'usage', tokensIn, tokensOut };
yield { type: 'done' };
} catch (e) {
yield {
type: 'error',
message: `Ollama stream error: ${(e as Error).message}`,
};
}
},
};
+112
View File
@@ -0,0 +1,112 @@
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
import { sseLines } from '../sse';
/**
* Generic chat-completions streamer used by both OpenAI and the
* "openai-compatible" provider (OpenRouter, LiteLLM, vLLM, Together,
* etc.). The wire format is the same — only the base URL differs.
*
* Streaming:
* - data: {choices:[{delta:{content:"..."}}]}
* - data: {choices:[{delta:{}}]} ← end-of-content
* - data: {usage:{prompt_tokens, completion_tokens}} (when stream_options.include_usage)
* - data: [DONE]
*/
export async function* generateOpenAIStyle(
opts: GenerateOpts,
baseUrl: string,
providerLabel: string,
): AsyncGenerator<GenerateChunk, void, void> {
if (!opts.apiKey) {
yield { type: 'error', message: `${providerLabel} API key is required.` };
return;
}
const url = baseUrl.replace(/\/$/, '') + '/chat/completions';
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${opts.apiKey}`,
},
body: JSON.stringify({
model: opts.model,
stream: true,
stream_options: { include_usage: true },
messages: [
{ role: 'system', content: opts.systemPrompt },
{ role: 'user', content: opts.userPrompt },
],
}),
signal: opts.signal,
});
} catch (e) {
yield {
type: 'error',
message: `${providerLabel} unreachable: ${(e as Error).message}`,
};
return;
}
if (!res.ok) {
yield {
type: 'error',
message: `${providerLabel} HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
};
return;
}
let tokensIn: number | undefined;
let tokensOut: number | undefined;
try {
for await (const data of sseLines(res)) {
if (data === '[DONE]') break;
let evt: any;
try {
evt = JSON.parse(data);
} catch {
continue;
}
const delta = evt.choices?.[0]?.delta?.content;
if (delta) yield { type: 'text', delta };
if (evt.usage) {
tokensIn = evt.usage.prompt_tokens;
tokensOut = evt.usage.completion_tokens;
}
}
yield { type: 'usage', tokensIn, tokensOut };
yield { type: 'done' };
} catch (e) {
yield {
type: 'error',
message: `${providerLabel} stream error: ${(e as Error).message}`,
};
}
}
export const openai: LLMProvider = {
id: 'openai',
label: 'OpenAI',
requiresApiKey: true,
requiresBaseUrl: false,
generate(opts) {
return generateOpenAIStyle(opts, 'https://api.openai.com/v1', 'OpenAI');
},
};
export const openaiCompatible: LLMProvider = {
id: 'openai-compatible',
label: 'OpenAI-compatible (custom URL)',
requiresApiKey: true,
requiresBaseUrl: true,
async *generate(opts) {
if (!opts.baseUrl) {
yield {
type: 'error',
message:
'Base URL is required (e.g. https://openrouter.ai/api/v1, your LiteLLM gateway, etc.).',
};
return;
}
yield* generateOpenAIStyle(opts, opts.baseUrl, 'OpenAI-compatible');
},
};
+67
View File
@@ -0,0 +1,67 @@
/**
* Minimal SSE-line iterator for provider responses.
*
* Reads a fetch response body as a stream and yields each `data:`
* payload exactly once. Handles event boundaries (`\n\n`), CRLF,
* and the "[DONE]" sentinel that OpenAI-style providers emit. Skips
* comments (lines starting with `:`).
*
* Usage:
* for await (const data of sseLines(response)) {
* if (data === '[DONE]') break;
* const evt = JSON.parse(data);
* ...
* }
*/
export async function* sseLines(
response: Response,
): AsyncGenerator<string, void, void> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const event = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const dataLines: string[] = [];
for (const raw of event.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line || line.startsWith(':')) continue;
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length > 0) yield dataLines.join('\n');
}
}
}
/**
* NDJSON line iterator (Ollama). Yields each non-empty line as a
* raw string (not parsed) — caller decides what to do with it.
*/
export async function* ndjsonLines(
response: Response,
): AsyncGenerator<string, void, void> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line) yield line;
}
}
if (buffer.trim()) yield buffer.trim();
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Provider-agnostic LLM streaming abstraction.
*
* Each provider implements `generate(opts)` returning an
* AsyncIterable that yields text deltas as the model produces them,
* then a final usage chunk + done. This normalizes the wildly
* different streaming formats (Anthropic SSE / OpenAI SSE / Gemini
* streaming JSON / Ollama NDJSON) behind one shape.
*
* The system prompt + user prompt + a strict-JSON instruction is
* what every provider receives. We DON'T use provider-specific
* "JSON mode" / "structured output" features because they aren't
* uniformly available (notably Ollama varies by model). Instead the
* prompt itself instructs the model to emit ONLY valid JSON; the
* server validates with Zod after.
*/
export type ProviderId =
| 'claude'
| 'openai'
| 'openai-compatible'
| 'gemini'
| 'ollama';
export interface GenerateOpts {
/** API key. Null/undefined for ollama on a trusted LAN. */
apiKey?: string | null;
/**
* Provider base URL.
* - openai-compatible: required (e.g. https://openrouter.ai/api/v1)
* - ollama: required (e.g. http://ollama.embassy:11434)
* - others: ignored, provider hits its hardcoded endpoint
*/
baseUrl?: string | null;
/** Model identifier as the provider expects it. */
model: string;
systemPrompt: string;
userPrompt: string;
/** AbortSignal for cancellation; the implementation must respect it. */
signal?: AbortSignal;
}
export type GenerateChunk =
| { type: 'text'; delta: string }
| { type: 'usage'; tokensIn?: number; tokensOut?: number }
| { type: 'done' }
| { type: 'error'; message: string };
export interface LLMProvider {
id: ProviderId;
/** Display label for the Settings UI dropdown. */
label: string;
/** Whether the provider needs an API key. */
requiresApiKey: boolean;
/** Whether the provider needs a custom base URL. */
requiresBaseUrl: boolean;
/** Async stream of chunks. */
generate(opts: GenerateOpts): AsyncIterable<GenerateChunk>;
}
@@ -0,0 +1,32 @@
[
{
"name": "Hypertrophy block",
"description": "Volume-focused muscle-building program. Push/Pull/Legs split, RPE-based progression.",
"systemPrompt": "You are a strength and conditioning coach designing a hypertrophy block. Use a Push/Pull/Legs/Rest split rotated through the week. 4-6 working sets per major muscle per session, 6-12 rep range, RPE 7-9. Compound lifts first, isolation/accessory work after. Include progression notes per week (volume up, then deload in the final week). Keep sessions to 60-75 minutes, 5-7 exercises. Pick exercises from the user's library; if you need a movement they don't have, set exerciseId to null and propose a name.",
"userPromptTemplate": "Build me a hypertrophy program with the following specifics:\n\n{{userInput}}\n\nDefault to 8 weeks if I didn't specify duration."
},
{
"name": "Strength block",
"description": "Periodized strength work centered on the big compound lifts.",
"systemPrompt": "You are a strength coach designing a periodized strength program. Squat/Bench/Deadlift (or close variations from the library) anchor each session, accessories support the main lift. Rep ranges 3-6 for primaries, 5-10 for accessories. Use linear or wave-loaded periodization across weeks: week 1 introductory, weeks 2-N progressive, final week deload. Sessions 4-6 exercises, 60-90 minutes. Include rest periods (3-5 min for primaries, 90-180s for accessories). Pick from the user's library; null exerciseId if a needed lift isn't there.",
"userPromptTemplate": "Design a strength block for me:\n\n{{userInput}}\n\nDefault to 6 weeks, 4 sessions per week if I didn't specify."
},
{
"name": "Endurance / running block",
"description": "Running-focused training: easy / tempo / interval / long-run progression.",
"systemPrompt": "You are an endurance coach designing a running-focused program. Each week has: 2-3 easy aerobic runs, 1 tempo or threshold session, 1 interval/speed session, 1 long run, 1-2 rest or cross-training days. Use Running, Cycling, Rowing as primary cardio. Include 1-2 strength accessory sessions per week (single-leg work, core, mobility). Distance-based exercises: log distance + duration + calories. Progress weekly volume by ~10%, deload every 4th week. Pick from the user's library; null exerciseId for missing items.",
"userPromptTemplate": "Design a running block:\n\n{{userInput}}\n\nDefault to 12 weeks, building toward the goal event if I mentioned one."
},
{
"name": "Recovery / deload week",
"description": "Single low-volume recovery week structured around what you've recently been doing.",
"systemPrompt": "You are designing a single deload week. Reduce volume by 40-50% from a normal block. Movement patterns stay the same but: lower sets, lower reps per set, lower intensity (RPE 5-7). Add mobility and easy aerobic work. Sessions 30-45 minutes. Output durationWeeks: 1, with a single week containing 4-5 days of light work + 2-3 rest days. Pick familiar exercises from the user's library.",
"userPromptTemplate": "Build me a deload week:\n\n{{userInput}}"
},
{
"name": "Custom",
"description": "Open-ended template — you describe everything in the prompt.",
"systemPrompt": "You are a strength and conditioning coach. The user describes the program they want; design it as JSON matching the OUTPUT SHAPE. Use exercises from the user's library (set exerciseId to null for movements they don't have). Be specific about sets, reps, RPE, and rest periods. Add coaching notes per exercise where relevant.",
"userPromptTemplate": "{{userInput}}"
}
]
@@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* ensurePromptTemplates — runs at every container boot from
* docker_entrypoint.sh. Reconciles the curated built-in AI prompt
* templates from /app/prisma/aiTemplates.seed.json into the
* AIPromptTemplate table. Built-ins have userId=NULL.
*
* - INSERT new built-in templates that don't exist by name yet.
* - UPDATE existing built-ins (userId IS NULL) by name to match
* the curated systemPrompt + userPromptTemplate. Maintainer-side
* fixes propagate to existing installs.
* - User-created templates (userId IS NOT NULL) are never touched
* here, even if a user happens to have created a template with
* the same name as a built-in.
*
* Idempotent. Cheap (5 templates × 1 transaction).
*/
const fs = require('fs');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
function arg(name) {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : null;
}
const dbPath = arg('--db');
const jsonPath = arg('--json');
if (!dbPath || !jsonPath) {
console.error(
'usage: ensurePromptTemplates.cjs --db <path> --json <path>',
);
process.exit(64);
}
if (!fs.existsSync(dbPath)) {
console.error(`[ensure-templates] db not found: ${dbPath}`);
process.exit(0);
}
if (!fs.existsSync(jsonPath)) {
console.error(`[ensure-templates] templates json not found: ${jsonPath}`);
process.exit(0);
}
// Defensive: only run if the table exists (compat ALTER may not have
// run yet on a brand-new install where seed.ts ran instead).
const hasTable = execFileSync(
'sqlite3',
[
dbPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIPromptTemplate';",
],
{ encoding: 'utf8' },
).trim();
if (!hasTable) {
console.error('[ensure-templates] AIPromptTemplate table not found; skipping');
process.exit(0);
}
const templates = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
if (!Array.isArray(templates) || templates.length === 0) {
console.error('[ensure-templates] templates array is empty; nothing to do');
process.exit(0);
}
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
const newId = () => 'c' + crypto.randomBytes(12).toString('hex');
// SQLite doesn't have a partial unique index easy to install via
// schema, so we manually upsert: SELECT-by-name-where-builtin first,
// then INSERT or UPDATE.
const stmts = ['BEGIN;'];
for (const t of templates) {
// Find existing built-in with this name. We use COALESCE-by-rowid
// to make this single-statement-friendly with INSERT OR REPLACE.
// Easier: do explicit lookup + branch in JS.
const existingRows = execFileSync(
'sqlite3',
[
dbPath,
`SELECT id FROM AIPromptTemplate WHERE userId IS NULL AND name = ${q(t.name)};`,
],
{ encoding: 'utf8' },
)
.split('\n')
.filter(Boolean);
const existingId = existingRows[0];
if (existingId) {
stmts.push(
`UPDATE AIPromptTemplate SET ` +
`description = ${t.description ? q(t.description) : 'NULL'}, ` +
`systemPrompt = ${q(t.systemPrompt)}, ` +
`userPromptTemplate = ${q(t.userPromptTemplate)}, ` +
`isBuiltIn = 1, ` +
`updatedAt = CURRENT_TIMESTAMP ` +
`WHERE id = ${q(existingId)};`,
);
} else {
stmts.push(
`INSERT INTO AIPromptTemplate ` +
`(id, userId, name, description, systemPrompt, userPromptTemplate, isBuiltIn, createdAt, updatedAt) ` +
`VALUES (${q(newId())}, NULL, ${q(t.name)}, ` +
`${t.description ? q(t.description) : 'NULL'}, ` +
`${q(t.systemPrompt)}, ${q(t.userPromptTemplate)}, ` +
`1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);`,
);
}
}
stmts.push('COMMIT;');
execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') });
console.error(
`[ensure-templates] reconciled ${templates.length} built-in template(s)`,
);
+88
View File
@@ -28,6 +28,8 @@ model User {
equipment Equipment[] equipment Equipment[]
contentItems ContentItem[] contentItems ContentItem[]
aiSuggestions AISuggestion[] aiSuggestions AISuggestion[]
aiPromptTemplates AIPromptTemplate[]
aiGenerations AIGeneration[]
userPreferences UserPreferences? userPreferences UserPreferences?
@@index([email]) @@index([email])
@@ -302,8 +304,28 @@ model UserPreferences {
theme String @default("system") // light, dark, system theme String @default("system") // light, dark, system
defaultWeightUnit String @default("lbs") defaultWeightUnit String @default("lbs")
defaultRestSeconds Int @default(90) defaultRestSeconds Int @default(90)
// ─── Dead fields, retained for back-compat ────────────────────
// enableClaudeAI / claudeApiKey were the v1.0.0:1-6 single-provider
// toggles. v1.0.0:7 removed the photo-import feature that used them
// and stopped reading/writing them. Kept as columns to avoid a
// destructive ALTER on existing data; new code uses the aiProvider/
// aiModel/aiBaseUrl/aiApiKey block below.
enableClaudeAI Boolean @default(false) enableClaudeAI Boolean @default(false)
claudeApiKey String? claudeApiKey String?
// ─── v1.1.0:2 model-agnostic AI configuration ─────────────────
// aiProvider: 'claude' | 'openai' | 'openai-compatible' | 'gemini'
// | 'ollama' | null (disabled)
// aiBaseUrl: required for openai-compatible + ollama; ignored
// otherwise. Use http://ollama.embassy:11434 for the
// StartOS sister service if it's installed.
// aiApiKey: plaintext (consistent with all other secrets in
// /data; the host-level threat model assumes /data
// is owned by the operator). Null for ollama on a
// trusted LAN.
aiProvider String?
aiModel String?
aiBaseUrl String?
aiApiKey String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -312,3 +334,69 @@ model UserPreferences {
@@index([userId]) @@index([userId])
} }
/// User-defined or shipped prompt templates for AI program generation.
/// `userId = null` means the template ships with the package (built-in,
/// reconciled per-boot from prisma/aiTemplates.seed.json). `userId =
/// <id>` means a user-created template, fully owned by them.
///
/// Built-in templates are read-only in the UI for non-admins (they
/// can clone-to-edit). Admins can edit built-ins via the same API but
/// edits won't survive the next boot's reconcile pass — to ship
/// changes, edit the JSON in the repo and bump a version.
model AIPromptTemplate {
id String @id @default(cuid())
userId String?
name String
description String?
/// Role + constraints sent as the system message to the LLM.
systemPrompt String
/// Body of the user message; supports {{userInput}} interpolation
/// so the user's specifics (e.g. "4 weeks heavy leg emphasis") get
/// stitched into the template.
userPromptTemplate String
isBuiltIn Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([isBuiltIn])
}
/// One row per AI generation request. Stores the exact prompts sent
/// (so the user can see why the model produced what it did), the raw
/// model output, the parsed program structure if parsing succeeded,
/// and a status that tracks the lifecycle:
///
/// pending → request in flight
/// completed → got a response, parsed it; preview available
/// failed → got an error or parse failure (errorMessage set)
/// applied → user clicked Apply, Program created (appliedProgramId set)
model AIGeneration {
id String @id @default(cuid())
userId String
templateId String?
templateName String?
userInput String
systemPrompt String
userPrompt String
rawResponse String?
parsedProgram String? // JSON.stringify of the parsed structure
provider String
model String
tokensIn Int?
tokensOut Int?
status String @default("pending")
errorMessage String?
appliedProgramId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([status])
@@index([appliedProgramId])
}
+249
View File
@@ -0,0 +1,249 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { prisma } from '@/lib/prisma';
import { applyAIProgram } from '@/lib/ai/apply';
import type { AIProgram } from '@/lib/ai/programSchema';
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.setLog.deleteMany();
await prisma.workout.deleteMany();
await prisma.programExercise.deleteMany();
await prisma.programDay.deleteMany();
await prisma.programWeek.deleteMany();
await prisma.program.deleteMany();
await prisma.exercise.deleteMany();
await prisma.aIGeneration.deleteMany();
await prisma.aIPromptTemplate.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
});
async function setup() {
const user = await prisma.user.create({
data: { email: 'a@x', passwordHash: 'fake' },
});
const bench = await prisma.exercise.create({
data: {
userId: user.id,
name: 'Bench Press',
type: 'barbell',
muscleGroups: '[]',
},
});
const squat = await prisma.exercise.create({
data: {
userId: user.id,
name: 'Squat',
type: 'barbell',
muscleGroups: '[]',
},
});
return { user, bench, squat };
}
describe('applyAIProgram', () => {
it('materializes a valid AIProgram into Program + nested rows + flips aiGenerated', async () => {
const { user, bench, squat } = await setup();
const ai: AIProgram = {
name: 'AI 4-week',
description: 'Test',
type: 'hypertrophy',
durationWeeks: 4,
weeks: [
{
weekNumber: 1,
phase: 'Volume',
days: [
{
dayOfWeek: 1,
name: 'Push',
exercises: [
{
exerciseId: bench.id,
exerciseName: 'Bench Press',
order: 0,
sets: 4,
repsMin: 6,
repsMax: 10,
rpe: 8,
},
],
description: null,
},
],
description: null,
},
{
weekNumber: 2,
days: [
{
dayOfWeek: 2,
exercises: [
{
exerciseId: squat.id,
exerciseName: 'Squat',
order: 0,
sets: 5,
repsMin: 5,
repsMax: 5,
rpe: 9,
restSeconds: 180,
notes: 'Pause for 1 second',
},
],
name: null,
description: null,
},
],
phase: null,
description: null,
},
],
};
const result = await applyAIProgram(prisma, ai, {
userId: user.id,
startDate: new Date('2026-05-10'),
isActive: false,
});
expect(result.weeksCreated).toBe(2);
expect(result.daysCreated).toBe(2);
expect(result.exercisesCreated).toBe(2);
const program = await prisma.program.findUnique({
where: { id: result.programId },
include: {
weeks: {
include: { days: { include: { exercises: true } } },
},
},
});
expect(program?.aiGenerated).toBe(true);
expect(program?.weeks).toHaveLength(2);
expect(program?.weeks[1].days[0].exercises[0].notes).toBe(
'Pause for 1 second',
);
});
it('rejects when an exerciseId references another user', async () => {
const { user } = await setup();
const otherUser = await prisma.user.create({
data: { email: 'b@x', passwordHash: 'fake' },
});
const otherEx = await prisma.exercise.create({
data: {
userId: otherUser.id,
name: 'Other ex',
type: 'barbell',
muscleGroups: '[]',
},
});
const ai: AIProgram = {
name: 'X',
type: 'h',
durationWeeks: 1,
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{
exerciseId: otherEx.id,
exerciseName: 'Other ex',
order: 0,
sets: 3,
},
],
},
],
},
],
};
await expect(
applyAIProgram(prisma, ai, {
userId: user.id,
startDate: new Date(),
}),
).rejects.toThrow(/don't belong to this user/);
});
it('rejects when any exercise still has null exerciseId (unresolved)', async () => {
const { user, bench } = await setup();
const ai: AIProgram = {
name: 'X',
type: 'h',
durationWeeks: 1,
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{
exerciseId: bench.id,
exerciseName: 'Bench Press',
order: 0,
sets: 4,
},
{
exerciseId: null,
exerciseName: 'Bulgarian Split Squat',
order: 1,
sets: 3,
},
],
},
],
},
],
};
await expect(
applyAIProgram(prisma, ai, {
userId: user.id,
startDate: new Date(),
}),
).rejects.toThrow(/unresolved/);
// Nothing should have been written.
expect(await prisma.program.count()).toBe(0);
});
it('honors isActive flag', async () => {
const { user, bench } = await setup();
const ai: AIProgram = {
name: 'Active program',
type: 'h',
durationWeeks: 1,
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{
exerciseId: bench.id,
exerciseName: 'Bench Press',
order: 0,
sets: 3,
},
],
},
],
},
],
};
const result = await applyAIProgram(prisma, ai, {
userId: user.id,
startDate: new Date(),
isActive: true,
});
const p = await prisma.program.findUnique({
where: { id: result.programId },
});
expect(p?.isActive).toBe(true);
});
});
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { extractJson, parseAIProgram } from '@/lib/ai/programSchema';
describe('extractJson', () => {
it('extracts a bare JSON object', () => {
expect(extractJson('{"a":1}')).toBe('{"a":1}');
});
it('strips ```json fences', () => {
expect(extractJson('```json\n{"a":1}\n```')).toBe('{"a":1}');
});
it('strips bare ``` fences', () => {
expect(extractJson('```\n{"a":1}\n```')).toBe('{"a":1}');
});
it('finds first balanced object after preamble', () => {
const raw =
'Here is your program:\n\n{"name":"X","weeks":[{"weekNumber":1,"days":[]}]}\n\nHope that helps!';
expect(extractJson(raw)).toBe(
'{"name":"X","weeks":[{"weekNumber":1,"days":[]}]}',
);
});
it('handles braces inside strings', () => {
const raw = '{"notes":"use {brackets} sparingly","x":1}';
expect(extractJson(raw)).toBe(raw);
});
it('returns null when no object present', () => {
expect(extractJson('no json at all')).toBeNull();
});
});
describe('parseAIProgram', () => {
const valid = {
name: 'Test',
type: 'hypertrophy',
durationWeeks: 4,
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{
exerciseId: 'cabc',
exerciseName: 'Bench Press',
order: 0,
sets: 4,
repsMin: 6,
repsMax: 10,
},
],
},
],
},
],
};
it('accepts a valid program', () => {
const r = parseAIProgram(JSON.stringify(valid));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.program.name).toBe('Test');
expect(r.program.weeks[0].days[0].exercises[0].exerciseName).toBe(
'Bench Press',
);
}
});
it('accepts null exerciseId for unresolved exercises', () => {
const variant = structuredClone(valid);
variant.weeks[0].days[0].exercises[0] = {
...variant.weeks[0].days[0].exercises[0],
exerciseId: null as unknown as string,
};
const r = parseAIProgram(JSON.stringify(variant));
expect(r.ok).toBe(true);
});
it('rejects when no JSON found', () => {
const r = parseAIProgram('the model just said hello');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
});
it('rejects malformed JSON', () => {
// Unbalanced braces: extractJson never finds a closing `}`, so
// the failure mode is "Could not find a JSON object" rather than
// a parse error per se. Either way, ok=false.
const r = parseAIProgram('{ "name": "x", "weeks": [');
expect(r.ok).toBe(false);
});
it('rejects JSON with a parse-level syntax error inside balanced braces', () => {
const r = parseAIProgram('{ "name": "x", }');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
});
it('rejects when shape is wrong (missing weeks)', () => {
const bad = { name: 'X', type: 'hypertrophy', durationWeeks: 4 };
const r = parseAIProgram(JSON.stringify(bad));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/shape/);
});
it('rejects when dayOfWeek is out of range', () => {
const variant = structuredClone(valid);
variant.weeks[0].days[0].dayOfWeek = 7; // 0-6 only
const r = parseAIProgram(JSON.stringify(variant));
expect(r.ok).toBe(false);
});
it('handles a model response wrapped in markdown commentary', () => {
const wrapped =
"Sure! Here's your program:\n\n```json\n" +
JSON.stringify(valid) +
'\n```\n\nLet me know if you want changes.';
const r = parseAIProgram(wrapped);
expect(r.ok).toBe(true);
});
});
@@ -0,0 +1,264 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import {
GET as listTemplates,
POST as createTemplate,
} from '@/app/api/ai/templates/route';
import {
PATCH as patchTemplate,
DELETE as deleteTemplate,
} from '@/app/api/ai/templates/[id]/route';
import { GET as getConfig, POST as setConfig } from '@/app/api/ai/config/route';
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
return new NextRequest(url, {
method: method ?? (body !== undefined ? 'POST' : 'GET'),
headers: { 'content-type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
} as ConstructorParameters<typeof NextRequest>[1]);
}
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.aIGeneration.deleteMany();
await prisma.aIPromptTemplate.deleteMany();
await prisma.userPreferences.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
describe('GET /api/ai/templates', () => {
it('returns built-ins + this user\'s own; not other users\' templates', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake' },
});
const other = await prisma.user.create({
data: { email: 'other@x', passwordHash: 'fake' },
});
await prisma.aIPromptTemplate.create({
data: {
userId: null,
name: 'Built-in 1',
systemPrompt: 's',
userPromptTemplate: 'u',
isBuiltIn: true,
},
});
await prisma.aIPromptTemplate.create({
data: {
userId: me.id,
name: 'My template',
systemPrompt: 's',
userPromptTemplate: 'u',
isBuiltIn: false,
},
});
await prisma.aIPromptTemplate.create({
data: {
userId: other.id,
name: "Other user's template",
systemPrompt: 's',
userPromptTemplate: 'u',
isBuiltIn: false,
},
});
getCurrentUserMock.mockResolvedValue(me);
const list = await (await listTemplates()).json();
const names = list.map((t: { name: string }) => t.name).sort();
expect(names).toEqual(['Built-in 1', 'My template']);
});
});
describe('POST /api/ai/templates', () => {
it('creates a user-owned template', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake' },
});
getCurrentUserMock.mockResolvedValue(me);
const res = await createTemplate(
jsonReq('http://x/api/ai/templates', {
name: 'My new',
systemPrompt: 'be a coach',
userPromptTemplate: '{{userInput}}',
}),
);
expect(res.status).toBe(201);
const tpl = await res.json();
expect(tpl.userId).toBe(me.id);
expect(tpl.isBuiltIn).toBe(false);
});
});
describe('PATCH /api/ai/templates/[id]', () => {
it('rejects edits to built-ins by non-admin', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake', isAdmin: false },
});
const builtin = await prisma.aIPromptTemplate.create({
data: {
userId: null,
name: 'Built-in',
systemPrompt: 's',
userPromptTemplate: 'u',
isBuiltIn: true,
},
});
getCurrentUserMock.mockResolvedValue(me);
const res = await patchTemplate(
jsonReq(
`http://x/api/ai/templates/${builtin.id}`,
{ name: 'tampered' },
'PATCH',
),
{ params: { id: builtin.id } },
);
expect(res.status).toBe(403);
});
it('allows edits to built-ins by admin', async () => {
const admin = await prisma.user.create({
data: { email: 'admin@x', passwordHash: 'fake', isAdmin: true },
});
const builtin = await prisma.aIPromptTemplate.create({
data: {
userId: null,
name: 'Built-in',
systemPrompt: 's',
userPromptTemplate: 'u',
isBuiltIn: true,
},
});
getCurrentUserMock.mockResolvedValue(admin);
const res = await patchTemplate(
jsonReq(
`http://x/api/ai/templates/${builtin.id}`,
{ name: 'admin-edited' },
'PATCH',
),
{ params: { id: builtin.id } },
);
expect(res.status).toBe(200);
const updated = await prisma.aIPromptTemplate.findUnique({
where: { id: builtin.id },
});
expect(updated?.name).toBe('admin-edited');
});
it('rejects edits to another user\'s template', async () => {
const alice = await prisma.user.create({
data: { email: 'alice@x', passwordHash: 'fake' },
});
const aliceTpl = await prisma.aIPromptTemplate.create({
data: {
userId: alice.id,
name: 'Alice template',
systemPrompt: 's',
userPromptTemplate: 'u',
},
});
const bob = await prisma.user.create({
data: { email: 'bob@x', passwordHash: 'fake' },
});
getCurrentUserMock.mockResolvedValue(bob);
const res = await patchTemplate(
jsonReq(
`http://x/api/ai/templates/${aliceTpl.id}`,
{ name: 'bob hacks' },
'PATCH',
),
{ params: { id: aliceTpl.id } },
);
expect(res.status).toBe(403);
});
});
describe('DELETE /api/ai/templates/[id]', () => {
it('lets user delete their own template', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake' },
});
const tpl = await prisma.aIPromptTemplate.create({
data: {
userId: me.id,
name: 'X',
systemPrompt: 's',
userPromptTemplate: 'u',
},
});
getCurrentUserMock.mockResolvedValue(me);
const res = await deleteTemplate(
jsonReq(`http://x/api/ai/templates/${tpl.id}`, undefined, 'DELETE'),
{ params: { id: tpl.id } },
);
expect(res.status).toBe(200);
expect(await prisma.aIPromptTemplate.count()).toBe(0);
});
});
describe('/api/ai/config', () => {
it('GET returns aiKeyConfigured flag, never the plaintext key', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake' },
});
await prisma.userPreferences.create({
data: {
userId: me.id,
aiProvider: 'claude',
aiModel: 'claude-sonnet-4-5',
aiApiKey: 'sk-ant-secret',
},
});
getCurrentUserMock.mockResolvedValue(me);
const body = await (await getConfig()).json();
expect(body.aiProvider).toBe('claude');
expect(body.aiModel).toBe('claude-sonnet-4-5');
expect(body.aiKeyConfigured).toBe(true);
expect(JSON.stringify(body)).not.toContain('sk-ant-secret');
});
it('POST persists provider config', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake' },
});
getCurrentUserMock.mockResolvedValue(me);
const res = await setConfig(
jsonReq('http://x/api/ai/config', {
aiProvider: 'ollama',
aiModel: 'llama3.1:8b',
aiBaseUrl: 'http://ollama.embassy:11434',
aiApiKey: null,
}),
);
expect(res.status).toBe(200);
const prefs = await prisma.userPreferences.findUnique({
where: { userId: me.id },
});
expect(prefs?.aiProvider).toBe('ollama');
expect(prefs?.aiBaseUrl).toBe('http://ollama.embassy:11434');
expect(prefs?.aiApiKey).toBeNull();
});
it('POST validates provider enum', async () => {
const me = await prisma.user.create({
data: { email: 'me@x', passwordHash: 'fake' },
});
getCurrentUserMock.mockResolvedValue(me);
const res = await setConfig(
jsonReq('http://x/api/ai/config', { aiProvider: 'made-up-provider' }),
);
expect(res.status).toBe(400);
});
});
+80
View File
@@ -105,6 +105,73 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "CREATE INDEX IF NOT EXISTS Workout_programDayId_idx ON Workout(programDayId);" sqlite3 "$DB_PATH" "CREATE INDEX IF NOT EXISTS Workout_programDayId_idx ON Workout(programDayId);"
fi fi
# v1.1.0:2 added the model-agnostic AI configuration fields to
# UserPreferences. Replaces the dead enableClaudeAI / claudeApiKey
# single-provider scheme (those columns stay as no-op fields for
# back-compat).
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('UserPreferences');" 2>/dev/null | grep -q "|aiProvider|"; then
log "adding AI configuration columns to UserPreferences"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiProvider TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiModel TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiBaseUrl TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiApiKey TEXT;"
fi
# v1.1.0:2 also added AIPromptTemplate + AIGeneration tables.
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIPromptTemplate';" \
2>/dev/null | grep -q AIPromptTemplate; then
log "creating AIPromptTemplate table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIPromptTemplate (
id TEXT PRIMARY KEY,
userId TEXT,
name TEXT NOT NULL,
description TEXT,
systemPrompt TEXT NOT NULL,
userPromptTemplate TEXT NOT NULL,
isBuiltIn INTEGER NOT NULL DEFAULT 0,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIPromptTemplate_userId_idx ON AIPromptTemplate(userId);
CREATE INDEX AIPromptTemplate_isBuiltIn_idx ON AIPromptTemplate(isBuiltIn);
"
fi
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIGeneration';" \
2>/dev/null | grep -q AIGeneration; then
log "creating AIGeneration table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIGeneration (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
templateId TEXT,
templateName TEXT,
userInput TEXT NOT NULL,
systemPrompt TEXT NOT NULL,
userPrompt TEXT NOT NULL,
rawResponse TEXT,
parsedProgram TEXT,
provider TEXT NOT NULL,
model TEXT NOT NULL,
tokensIn INTEGER,
tokensOut INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
errorMessage TEXT,
appliedProgramId TEXT,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIGeneration_userId_createdAt_idx ON AIGeneration(userId, createdAt);
CREATE INDEX AIGeneration_status_idx ON AIGeneration(status);
CREATE INDEX AIGeneration_appliedProgramId_idx ON AIGeneration(appliedProgramId);
"
fi
if ! sqlite3 "$DB_PATH" \ if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \ "SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
2>/dev/null | grep -q InstanceSettings; then 2>/dev/null | grep -q InstanceSettings; then
@@ -173,6 +240,19 @@ else
log "skipping library ensure (json or db not found)" log "skipping library ensure (json or db not found)"
fi fi
# v1.1.0:2 — reconcile built-in AI prompt templates from the curated
# JSON. Same INSERT-or-UPDATE pattern as the exercise library, scoped
# to userId IS NULL so user-created templates are never touched.
TEMPLATES_JSON_PATH="${WORKOUT_TEMPLATES_JSON_PATH:-/app/prisma/aiTemplates.seed.json}"
TEMPLATES_SCRIPT="/app/prisma/ensurePromptTemplates.cjs"
if [ -f "$TEMPLATES_JSON_PATH" ] && [ -f "$TEMPLATES_SCRIPT" ] && [ -f "$DB_PATH" ]; then
log "ensuring built-in AI prompt templates are present"
node "$TEMPLATES_SCRIPT" \
--db "$DB_PATH" \
--json "$TEMPLATES_JSON_PATH" \
|| log "WARNING: ensurePromptTemplates failed; continuing boot"
fi
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Step 4 — launch the app. # Step 4 — launch the app.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+5 -1
View File
@@ -7,6 +7,7 @@ import { v_1_0_0_5 } from './v1.0.0.5'
import { v_1_0_0_6 } from './v1.0.0.6' import { v_1_0_0_6 } from './v1.0.0.6'
import { v_1_0_0_7 } from './v1.0.0.7' import { v_1_0_0_7 } from './v1.0.0.7'
import { v_1_1_0_1 } from './v1.1.0.1' import { v_1_1_0_1 } from './v1.1.0.1'
import { v_1_1_0_2 } from './v1.1.0.2'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -22,9 +23,11 @@ import { v_1_1_0_1 } from './v1.1.0.1'
* v1.0.0:6 — paginate workout history (infinite scroll). * v1.0.0:6 — paginate workout history (infinite scroll).
* v1.0.0:7 — exercise library cleanup, photo-import removal. * v1.0.0:7 — exercise library cleanup, photo-import removal.
* v1.1.0:1 — Programs UI (manual create / save / follow). * v1.1.0:1 — Programs UI (manual create / save / follow).
* v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI /
* OpenAI-compatible / Gemini / Ollama).
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_1_0_1, current: v_1_1_0_2,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -33,5 +36,6 @@ export const versionGraph = VersionGraph.of({
v_1_0_0_5, v_1_0_0_5,
v_1_0_0_6, v_1_0_0_6,
v_1_0_0_7, v_1_0_0_7,
v_1_1_0_1,
], ],
}) })
+62
View File
@@ -0,0 +1,62 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.1.0:2 — model-agnostic AI program generation.
*
* 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; uses LAN URL)
*
* The "self-hosted Ollama on Start9" angle is the killer use case —
* point Settings → AI integration → Base URL at
* `http://ollama.embassy:11434` (or whatever Ollama service you have
* on the same StartOS host) and no API keys ever leave your network.
*
* Workflow
* 1. Settings → AI integration: pick provider + model + key/URL.
* 2. AI → Generate program: pick a template, type your specifics
* ("8 weeks heavy leg emphasis"), click Generate.
* 3. Watch the response stream in word-by-word via SSE.
* 4. Server validates the JSON output against a Zod schema and
* stores both raw + parsed in AIGeneration.
* 5. Preview UI shows the program tree. Unknown exercises (the
* model picked something not in your library) are highlighted
* and you can map them to existing entries or remove them.
* 6. Apply → materializes into a real Program (same schema/UI as
* the v1.1.0:1 manual programs).
*
* Ships with 5 built-in prompt templates (hypertrophy block,
* strength block, endurance/running block, recovery week, custom).
* Built-ins reconcile per-boot the same way curated exercises do.
* Both admin and regular users can create their own templates.
*
* Schema additions
* - UserPreferences: aiProvider, aiModel, aiBaseUrl, aiApiKey
* (plaintext — consistent with the rest of /data/app.db; the
* host-level threat model assumes the operator owns /data).
* - AIPromptTemplate (built-ins userId=NULL, user templates
* userId=<them>).
* - AIGeneration (one row per generate request; raw response,
* parsed program, status, applied program id, token counts).
*
* Backward compatible: existing UserPreferences rows get the new
* columns added with NULL defaults (compat ALTER on first boot);
* the dead enableClaudeAI / claudeApiKey columns from v1.0.0:1-7
* stay as no-op fields.
*/
export const v_1_1_0_2 = VersionInfo.of({
version: '1.1.0:2',
releaseNotes: {
en_US:
'AI program generation. Pick from Claude / OpenAI / Gemini / OpenAI-compatible / self-hosted Ollama. Settings → AI integration to configure (Ollama on Start9 needs no API key). AI → Generate program to pick a template, describe what you want, watch the response stream in, review the parsed program, and apply it to your Programs library. Ships 5 starter templates; both admin and regular users can create their own. Generation history is kept until you delete it (per-row Trash; admin-only "clear all" via /api/admin/ai/generations).',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})