From 974c3eb07d78d64f5c06942f085af586a9cb5566 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sun, 10 May 2026 15:35:35 -0500 Subject: [PATCH] =?UTF-8?q?v1.1.0:2=20=E2=80=94=20model-agnostic=20AI=20pr?= =?UTF-8?q?ogram=20generation=20(5=20providers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/api/admin/ai/generations/route.ts | 44 ++ proof-of-work/app/api/ai/apply/route.ts | 104 +++ proof-of-work/app/api/ai/config/route.ts | 80 +++ proof-of-work/app/api/ai/generate/route.ts | 265 ++++++++ .../app/api/ai/generations/[id]/route.ts | 46 ++ proof-of-work/app/api/ai/generations/route.ts | 42 ++ .../app/api/ai/templates/[id]/route.ts | 104 +++ proof-of-work/app/api/ai/templates/route.ts | 62 ++ proof-of-work/app/main/ai/generate/page.tsx | 82 +++ proof-of-work/app/main/ai/history/page.tsx | 55 ++ proof-of-work/app/main/ai/page.tsx | 91 +++ proof-of-work/app/main/ai/templates/page.tsx | 50 ++ proof-of-work/app/main/navigation.tsx | 2 + proof-of-work/app/main/settings/page.tsx | 2 + .../components/ai/GenerateClient.tsx | 615 ++++++++++++++++++ proof-of-work/components/ai/HistoryList.tsx | 131 ++++ proof-of-work/components/ai/TemplatesList.tsx | 342 ++++++++++ .../components/settings/AIIntegration.tsx | 221 +++++++ proof-of-work/lib/ai/apply.ts | 136 ++++ proof-of-work/lib/ai/programSchema.ts | 176 +++++ proof-of-work/lib/ai/providers/claude.ts | 103 +++ proof-of-work/lib/ai/providers/gemini.ts | 89 +++ proof-of-work/lib/ai/providers/index.ts | 28 + proof-of-work/lib/ai/providers/ollama.ts | 83 +++ proof-of-work/lib/ai/providers/openai.ts | 112 ++++ proof-of-work/lib/ai/sse.ts | 67 ++ proof-of-work/lib/ai/types.ts | 59 ++ proof-of-work/prisma/aiTemplates.seed.json | 32 + .../prisma/ensurePromptTemplates.cjs | 115 ++++ proof-of-work/prisma/schema.prisma | 88 +++ proof-of-work/tests/ai-apply.test.ts | 249 +++++++ proof-of-work/tests/ai-programSchema.test.ts | 120 ++++ .../tests/routes-ai-templates.test.ts | 264 ++++++++ start9/0.4/docker_entrypoint.sh | 80 +++ start9/0.4/startos/versions/index.ts | 6 +- start9/0.4/startos/versions/v1.1.0.2.ts | 62 ++ 36 files changed, 4206 insertions(+), 1 deletion(-) create mode 100644 proof-of-work/app/api/admin/ai/generations/route.ts create mode 100644 proof-of-work/app/api/ai/apply/route.ts create mode 100644 proof-of-work/app/api/ai/config/route.ts create mode 100644 proof-of-work/app/api/ai/generate/route.ts create mode 100644 proof-of-work/app/api/ai/generations/[id]/route.ts create mode 100644 proof-of-work/app/api/ai/generations/route.ts create mode 100644 proof-of-work/app/api/ai/templates/[id]/route.ts create mode 100644 proof-of-work/app/api/ai/templates/route.ts create mode 100644 proof-of-work/app/main/ai/generate/page.tsx create mode 100644 proof-of-work/app/main/ai/history/page.tsx create mode 100644 proof-of-work/app/main/ai/page.tsx create mode 100644 proof-of-work/app/main/ai/templates/page.tsx create mode 100644 proof-of-work/components/ai/GenerateClient.tsx create mode 100644 proof-of-work/components/ai/HistoryList.tsx create mode 100644 proof-of-work/components/ai/TemplatesList.tsx create mode 100644 proof-of-work/components/settings/AIIntegration.tsx create mode 100644 proof-of-work/lib/ai/apply.ts create mode 100644 proof-of-work/lib/ai/programSchema.ts create mode 100644 proof-of-work/lib/ai/providers/claude.ts create mode 100644 proof-of-work/lib/ai/providers/gemini.ts create mode 100644 proof-of-work/lib/ai/providers/index.ts create mode 100644 proof-of-work/lib/ai/providers/ollama.ts create mode 100644 proof-of-work/lib/ai/providers/openai.ts create mode 100644 proof-of-work/lib/ai/sse.ts create mode 100644 proof-of-work/lib/ai/types.ts create mode 100644 proof-of-work/prisma/aiTemplates.seed.json create mode 100644 proof-of-work/prisma/ensurePromptTemplates.cjs create mode 100644 proof-of-work/tests/ai-apply.test.ts create mode 100644 proof-of-work/tests/ai-programSchema.test.ts create mode 100644 proof-of-work/tests/routes-ai-templates.test.ts create mode 100644 start9/0.4/startos/versions/v1.1.0.2.ts diff --git a/proof-of-work/app/api/admin/ai/generations/route.ts b/proof-of-work/app/api/admin/ai/generations/route.ts new file mode 100644 index 0000000..5eb5d7b --- /dev/null +++ b/proof-of-work/app/api/admin/ai/generations/route.ts @@ -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 = {}; + 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 }); +} diff --git a/proof-of-work/app/api/ai/apply/route.ts b/proof-of-work/app/api/ai/apply/route.ts new file mode 100644 index 0000000..6925439 --- /dev/null +++ b/proof-of-work/app/api/ai/apply/route.ts @@ -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 }, + ); + } +} diff --git a/proof-of-work/app/api/ai/config/route.ts b/proof-of-work/app/api/ai/config/route.ts new file mode 100644 index 0000000..9c3146f --- /dev/null +++ b/proof-of-work/app/api/ai/config/route.ts @@ -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 = {}; + 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 }); +} diff --git a/proof-of-work/app/api/ai/generate/route.ts b/proof-of-work/app/api/ai/generate/route.ts new file mode 100644 index 0000000..22ffcfc --- /dev/null +++ b/proof-of-work/app/api/ai/generate/route.ts @@ -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({ + 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.`; diff --git a/proof-of-work/app/api/ai/generations/[id]/route.ts b/proof-of-work/app/api/ai/generations/[id]/route.ts new file mode 100644 index 0000000..804f907 --- /dev/null +++ b/proof-of-work/app/api/ai/generations/[id]/route.ts @@ -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 }); +} diff --git a/proof-of-work/app/api/ai/generations/route.ts b/proof-of-work/app/api/ai/generations/route.ts new file mode 100644 index 0000000..1352241 --- /dev/null +++ b/proof-of-work/app/api/ai/generations/route.ts @@ -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, + }); +} diff --git a/proof-of-work/app/api/ai/templates/[id]/route.ts b/proof-of-work/app/api/ai/templates/[id]/route.ts new file mode 100644 index 0000000..b84472e --- /dev/null +++ b/proof-of-work/app/api/ai/templates/[id]/route.ts @@ -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 }, + ); + } +} diff --git a/proof-of-work/app/api/ai/templates/route.ts b/proof-of-work/app/api/ai/templates/route.ts new file mode 100644 index 0000000..f193ec9 --- /dev/null +++ b/proof-of-work/app/api/ai/templates/route.ts @@ -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 }, + ); + } +} diff --git a/proof-of-work/app/main/ai/generate/page.tsx b/proof-of-work/app/main/ai/generate/page.tsx new file mode 100644 index 0000000..da6f733 --- /dev/null +++ b/proof-of-work/app/main/ai/generate/page.tsx @@ -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 ( +
+
+
+ + + +

+ AI · Generate program +

+
+
+
+ {!aiConfigured ? ( +
+

+ AI is not configured. +

+

+ Pick a provider, model, and (if needed) API key in{' '} + + Settings → AI integration + {' '} + before you can generate programs. +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/proof-of-work/app/main/ai/history/page.tsx b/proof-of-work/app/main/ai/history/page.tsx new file mode 100644 index 0000000..43b5be5 --- /dev/null +++ b/proof-of-work/app/main/ai/history/page.tsx @@ -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 ( +
+
+
+ + + +

+ AI · Generation history +

+
+
+
+ ({ + ...r, + createdAt: r.createdAt.toISOString(), + }))} + /> +
+
+ ); +} diff --git a/proof-of-work/app/main/ai/page.tsx b/proof-of-work/app/main/ai/page.tsx new file mode 100644 index 0000000..e85ede6 --- /dev/null +++ b/proof-of-work/app/main/ai/page.tsx @@ -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 ( +
+
+
+

AI

+

+ {aiConfigured ? ( + <> + {prefs!.aiProvider} · {prefs!.aiModel} + + ) : ( + + Configure in Settings → + + )} +

+
+
+
+ {cards.map((c) => { + const Icon = c.icon; + const disabled = !!c.disabled; + return ( + +
+ +
+

+ {c.title} + {disabled && ( + + configure first + + )} +

+

{c.blurb}

+
+
+ + ); + })} +
+
+ ); +} diff --git a/proof-of-work/app/main/ai/templates/page.tsx b/proof-of-work/app/main/ai/templates/page.tsx new file mode 100644 index 0000000..ebf8337 --- /dev/null +++ b/proof-of-work/app/main/ai/templates/page.tsx @@ -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 ( +
+
+
+ + + +

+ AI · Prompt templates +

+
+
+
+ ({ + 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} + /> +
+
+ ); +} diff --git a/proof-of-work/app/main/navigation.tsx b/proof-of-work/app/main/navigation.tsx index 2801042..380b383 100644 --- a/proof-of-work/app/main/navigation.tsx +++ b/proof-of-work/app/main/navigation.tsx @@ -6,6 +6,7 @@ import { Dumbbell, ListChecks, Calendar, + Sparkles, Settings, LogOut, } from 'lucide-react'; @@ -19,6 +20,7 @@ const navLinks = [ { href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/main/workouts', label: 'Workouts', icon: Dumbbell }, { href: '/main/programs', label: 'Programs', icon: Calendar }, + { href: '/main/ai', label: 'AI', icon: Sparkles }, { href: '/main/exercises', label: 'Exercises', icon: ListChecks }, { href: '/main/settings', label: 'Settings', icon: Settings }, ]; diff --git a/proof-of-work/app/main/settings/page.tsx b/proof-of-work/app/main/settings/page.tsx index 1fc224c..9b661ae 100644 --- a/proof-of-work/app/main/settings/page.tsx +++ b/proof-of-work/app/main/settings/page.tsx @@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/auth"; import SettingsForm from "@/components/settings/SettingsForm"; import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; import SessionsList from "@/components/settings/SessionsList"; +import AIIntegration from "@/components/settings/AIIntegration"; import ExportMyData from "@/components/settings/ExportMyData"; import DangerZone from "@/components/settings/DangerZone"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; @@ -32,6 +33,7 @@ export default async function SettingsPage() { + {user.isAdmin && instanceSettings && ( (null); + const [phase, setPhase] = useState({ kind: 'idle' }); + const [tokens, setTokens] = useState<{ in?: number; out?: number }>({}); + const abortRef = useRef(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(null); + useEffect(() => { + generationIdRef.current = generationId; + }, [generationId]); + + const handleCancel = () => { + abortRef.current?.abort(); + }; + + return ( +
+
+ Provider: {providerLabel} + {' · '}Model: {modelLabel} +
+ +
+ + + {selectedTemplate?.description && ( +

+ {selectedTemplate.description} +

+ )} +
+ + +