diff --git a/proof-of-work/app/api/ai/configs/[id]/activate/route.ts b/proof-of-work/app/api/ai/configs/[id]/activate/route.ts new file mode 100644 index 0000000..3a491a5 --- /dev/null +++ b/proof-of-work/app/api/ai/configs/[id]/activate/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { activate } from '@/lib/ai/activateConfig'; + +/** + * POST /api/ai/configs/[id]/activate + * + * Set the named profile as the actor's active AI config. Mirrors the + * profile's fields into UserPreferences (legacy single-config columns) + * so api/ai/generate + api/ai/test continue to work as-is. + */ +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const profile = await prisma.aIConfigProfile.findFirst({ + where: { id: params.id, userId: user.id }, + }); + if (!profile) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + await activate(user.id, profile.id, { + provider: profile.provider, + model: profile.model, + baseUrl: profile.baseUrl, + apiKey: profile.apiKey, + }); + + return NextResponse.json({ success: true, activeId: profile.id }); +} diff --git a/proof-of-work/app/api/ai/configs/[id]/route.ts b/proof-of-work/app/api/ai/configs/[id]/route.ts new file mode 100644 index 0000000..91bffe2 --- /dev/null +++ b/proof-of-work/app/api/ai/configs/[id]/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { activate } from '@/lib/ai/activateConfig'; + +/** + * GET /api/ai/configs/[id] Single config (apiKey redacted). + * PATCH /api/ai/configs/[id] Update fields. Empty/null clears. + * Re-mirrors to UserPreferences if active. + * DELETE /api/ai/configs/[id] Remove. If it was active, falls back to + * the most-recently-created remaining + * profile (or clears if none left). + */ + +export async function GET( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const p = await prisma.aIConfigProfile.findFirst({ + where: { id: params.id, userId: user.id }, + select: { + id: true, + name: true, + provider: true, + model: true, + baseUrl: true, + apiKey: true, + createdAt: true, + }, + }); + if (!p) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ + id: p.id, + name: p.name, + provider: p.provider, + model: p.model, + baseUrl: p.baseUrl, + keyConfigured: !!p.apiKey, + createdAt: p.createdAt.toISOString(), + }); +} + +const patchSchema = z.object({ + name: z.string().min(1).max(80).optional(), + model: z.string().min(1).max(200).optional(), + baseUrl: z.string().url().nullable().optional().or(z.literal('')), + apiKey: z.string().nullable().optional(), +}); + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } }, +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + 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 existing = await prisma.aIConfigProfile.findFirst({ + where: { id: params.id, userId: user.id }, + }); + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const data: Record = {}; + if (parsed.data.name !== undefined) data.name = parsed.data.name; + if (parsed.data.model !== undefined) data.model = parsed.data.model; + if (parsed.data.baseUrl !== undefined) + data.baseUrl = parsed.data.baseUrl || null; + if (parsed.data.apiKey !== undefined) + data.apiKey = parsed.data.apiKey || null; + + const updated = await prisma.aIConfigProfile.update({ + where: { id: params.id }, + data, + }); + + // If this was the active config, mirror the new fields back into + // UserPreferences so existing read paths (api/ai/test, api/ai/generate + // current implementation) see the latest values. + const prefs = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + select: { activeAIConfigId: true }, + }); + if (prefs?.activeAIConfigId === params.id) { + await activate(user.id, params.id, { + provider: updated.provider, + model: updated.model, + baseUrl: updated.baseUrl, + apiKey: updated.apiKey, + }); + } + + return NextResponse.json({ success: true }); +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const existing = await prisma.aIConfigProfile.findFirst({ + where: { id: params.id, userId: user.id }, + }); + if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + await prisma.aIConfigProfile.delete({ where: { id: params.id } }); + + // If we just deleted the active config, demote-or-remove gracefully. + const prefs = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + select: { activeAIConfigId: true }, + }); + if (prefs?.activeAIConfigId === params.id) { + const fallback = await prisma.aIConfigProfile.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + if (fallback) { + await activate(user.id, fallback.id, { + provider: fallback.provider, + model: fallback.model, + baseUrl: fallback.baseUrl, + apiKey: fallback.apiKey, + }); + } else { + await prisma.userPreferences.update({ + where: { userId: user.id }, + data: { + activeAIConfigId: null, + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiApiKey: null, + }, + }); + } + } + + return NextResponse.json({ success: true }); +} diff --git a/proof-of-work/app/api/ai/configs/route.ts b/proof-of-work/app/api/ai/configs/route.ts new file mode 100644 index 0000000..5f1ffe7 --- /dev/null +++ b/proof-of-work/app/api/ai/configs/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { activate } from '@/lib/ai/activateConfig'; + +/** + * v1.1.0:4 — Multi-config CRUD. + * + * GET /api/ai/configs List the actor's saved AI configs + + * their active id. apiKey is REDACTED in + * list output (only `keyConfigured: bool`). + * POST /api/ai/configs Create a new config. Pass `setActive: true` + * to also activate it. + * + * Per-row endpoints in [id]/route.ts. "Activate" is its own POST in + * [id]/activate/route.ts so the action is explicit + auditable. + */ + +const PROVIDERS = ['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'] as const; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const [profiles, prefs] = await Promise.all([ + prisma.aIConfigProfile.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + name: true, + provider: true, + model: true, + baseUrl: true, + apiKey: true, // pulled only to compute keyConfigured; never returned + createdAt: true, + }, + }), + prisma.userPreferences.findUnique({ + where: { userId: user.id }, + select: { activeAIConfigId: true }, + }), + ]); + + return NextResponse.json({ + activeId: prefs?.activeAIConfigId ?? null, + configs: profiles.map((p) => ({ + id: p.id, + name: p.name, + provider: p.provider, + model: p.model, + baseUrl: p.baseUrl, + keyConfigured: !!p.apiKey, + createdAt: p.createdAt.toISOString(), + })), + }); +} + +const createSchema = z.object({ + name: z.string().min(1).max(80).optional(), + provider: z.enum(PROVIDERS), + model: z.string().min(1).max(200), + baseUrl: z.string().url().nullable().optional().or(z.literal('')), + apiKey: z.string().nullable().optional(), + setActive: z.boolean().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 = createSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid body', details: parsed.error.errors }, + { status: 400 }, + ); + } + + const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data; + const profile = await prisma.aIConfigProfile.create({ + data: { + userId: user.id, + name: name ?? defaultName(provider, model), + provider, + model, + baseUrl: baseUrl || null, + apiKey: apiKey || null, + }, + }); + + if (setActive) { + await activate(user.id, profile.id, { provider, model, baseUrl, apiKey }); + } + + return NextResponse.json({ + id: profile.id, + name: profile.name, + provider: profile.provider, + model: profile.model, + baseUrl: profile.baseUrl, + keyConfigured: !!profile.apiKey, + activated: !!setActive, + }); +} + +function defaultName(provider: string, model: string): string { + const PRETTY: Record = { + claude: 'Claude', + openai: 'OpenAI', + 'openai-compatible': 'Custom', + gemini: 'Gemini', + ollama: 'Ollama', + }; + const label = PRETTY[provider] ?? provider; + return `${label} · ${model}`; +} diff --git a/proof-of-work/app/api/ai/generate/route.ts b/proof-of-work/app/api/ai/generate/route.ts index a0c9e91..19fea5f 100644 --- a/proof-of-work/app/api/ai/generate/route.ts +++ b/proof-of-work/app/api/ai/generate/route.ts @@ -1,48 +1,36 @@ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } 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'; import { buildHistorySummary, formatHistoryContext, } from '@/lib/ai/historyContext'; +import { buildBaseSystemPrompt } from '@/lib/ai/systemPromptBase'; +import { kickoffGeneration } from '@/lib/ai/generationRunner'; /** * POST /api/ai/generate * - * Body: { templateId?: string, userInput: string } + * Body: { templateId?: string, userInput: string, includeHistory?: boolean } * - * 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":"..."} + * v1.1.0:4: this endpoint now KICKS OFF a background runner and returns + * the new generation id immediately. The caller subscribes to live + * deltas via GET /api/ai/generations/[id]/stream (SSE) or polls via + * GET /api/ai/generations/[id]. Navigating away no longer cancels the + * generation — the runner keeps writing to the row in the background. * - * 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. + * Response: + * 201 { id: "...generationId..." } + * 400 { error: "..." } */ const bodySchema = z.object({ templateId: z.string().optional().nullable(), userInput: z.string().min(1), - /** - * When true, build + append a compact summary of the user's - * recent (90-day) workout history to the system prompt. Lets the - * model design around stagnations, current strength levels, and - * actual training frequency. - */ includeHistory: z.boolean().optional().default(false), }); @@ -51,53 +39,34 @@ 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' }, - }); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } 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' } }, + return NextResponse.json( + { error: 'Invalid body', details: parsed.error.errors }, + { status: 400 }, ); } - // 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({ + return NextResponse.json( + { 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' } }, + }, + { status: 400 }, ); } - // Load the template if provided, else use a no-op default. + // Load the template if provided. let template: - | { - id: string; - name: string; - systemPrompt: string; - userPromptTemplate: string; - } + | { id: string; name: string; systemPrompt: string; userPromptTemplate: string } | null = null; if (parsed.data.templateId) { const t = await prisma.aIPromptTemplate.findFirst({ @@ -113,23 +82,15 @@ export async function POST(request: NextRequest) { }, }); if (!t) { - return new Response( - JSON.stringify({ error: 'Template not found.' }), - { status: 404, headers: { 'content-type': 'application/json' } }, - ); + return NextResponse.json({ error: 'Template not found.' }, { status: 404 }); } template = t; } - // Load the user's exercise library to embed in the system prompt. + // Library for the prompt. const exercises = await prisma.exercise.findMany({ where: { userId: user.id }, - select: { - id: true, - name: true, - type: true, - muscleGroups: true, - }, + select: { id: true, name: true, type: true, muscleGroups: true }, }); const libraryJson = JSON.stringify( exercises.map((e) => ({ @@ -146,138 +107,58 @@ export async function POST(request: NextRequest) { })), ); - // If requested, build the workout-history summary block. + // History context if requested. let historyBlock = ''; if (parsed.data.includeHistory) { const summary = await buildHistorySummary(prisma, user.id); historyBlock = formatHistoryContext(summary); } - // Stitch the final system + user prompts. - const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; - const systemPrompt = `${baseSystem} + // v1.1.0:4 base prompt with output contract + weight rules. Stitched + // BEFORE the template's coaching philosophy so output rules win when + // they conflict. + const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs'; + const isLocalModel = prefs.aiProvider === 'ollama'; + const basePrompt = buildBaseSystemPrompt({ + weightUnit, + hasHistoryContext: parsed.data.includeHistory, + isLocalModel, + }); + const templatePrompt = template?.systemPrompt ?? DEFAULT_TEMPLATE_PROMPT; + + const systemPrompt = `${basePrompt} + +# COACHING PHILOSOPHY (template-specific) + +${templatePrompt} + +# OUTPUT SHAPE -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. +# LIBRARY (use these exerciseIds; do not invent ids) + ${libraryJson}${historyBlock}`; 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', - }, + const id = await kickoffGeneration({ + prisma, + userId: user.id, + templateId: template?.id ?? null, + templateName: template?.name ?? null, + userInput: parsed.data.userInput, + systemPrompt, + userPrompt: userPromptBody, + provider: prefs.aiProvider, + model: prefs.aiModel, + apiKey: prefs.aiApiKey, + baseUrl: prefs.aiBaseUrl, }); - // 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 - }, - }); + return NextResponse.json({ id }, { status: 201 }); } -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.`; +const DEFAULT_TEMPLATE_PROMPT = `You are a strength and conditioning coach. The user will describe what they want; design a program that matches their goal, experience, equipment, and time budget. Pick exercises from the LIBRARY and stay close to evidence-based programming for the requested goal (hypertrophy / strength / power / conditioning / general fitness).`; diff --git a/proof-of-work/app/api/ai/generations/[id]/stream/route.ts b/proof-of-work/app/api/ai/generations/[id]/stream/route.ts new file mode 100644 index 0000000..9b2d4b0 --- /dev/null +++ b/proof-of-work/app/api/ai/generations/[id]/stream/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { subscribe } from '@/lib/ai/generationRunner'; + +/** + * GET /api/ai/generations/[id]/stream + * + * SSE attach to an in-flight generation. The runner that POST + * /api/ai/generate kicked off lives in this Node process; this + * endpoint subscribes to its in-memory bus and forwards each delta + * as an SSE event. + * + * Late-joining (after some text has streamed): the runner buffers + * everything emitted so far, and the subscription replays the buffer + * on attach, so refresh / new tab catches up cleanly. + * + * Already-finished: subscribe() replays the buffer and returns a + * no-op unsubscribe. We close the connection right after the buffer + * drains. + * + * Cross-process resume (pod restart, separate process): the in-memory + * bus is empty, so the SSE will be silent. The client should fall + * back to polling /api/ai/generations/[id] for `progressText` until + * the row hits a terminal status. The Generate UI does this. + */ + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } }, +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + // Authorize. + const row = await prisma.aIGeneration.findFirst({ + where: { id: params.id, userId: user.id }, + select: { id: true, status: true, progressText: true, errorMessage: true, parsedProgram: true, tokensIn: true, tokensOut: true, durationMs: true }, + }); + if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const encoder = new TextEncoder(); + const send = (controller: ReadableStreamDefaultController, event: string, data: unknown) => + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + const safeClose = () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + }; + + // First: send a `generation` event with the id so clients can + // confirm what they attached to (and consume the same protocol + // their old code expected). + send(controller, 'generation', { id: params.id }); + + // If the row already finished while we weren't looking, send + // its known progress + complete + close. (Cross-process resume + // OR fast finish before subscribe attached.) + if (row.status !== 'pending') { + if (row.progressText) { + send(controller, 'text', { delta: row.progressText }); + } + send(controller, 'complete', { + parsedOk: row.status === 'completed' || row.status === 'applied', + errorMessage: row.errorMessage ?? undefined, + tokensIn: row.tokensIn ?? undefined, + tokensOut: row.tokensOut ?? undefined, + durationMs: row.durationMs ?? undefined, + }); + safeClose(); + return; + } + + const unsub = subscribe(params.id, (d) => { + if (closed) return; + if (d.type === 'text') send(controller, 'text', { delta: d.delta }); + else if (d.type === 'usage') + send(controller, 'usage', { + tokensIn: d.tokensIn, + tokensOut: d.tokensOut, + }); + else if (d.type === 'complete') { + send(controller, 'complete', { + parsedOk: d.parsedOk, + errorMessage: d.errorMessage, + tokensIn: d.tokensIn, + tokensOut: d.tokensOut, + durationMs: d.durationMs, + }); + safeClose(); + } else if (d.type === 'error') { + send(controller, 'complete', { + parsedOk: false, + errorMessage: d.errorMessage, + }); + safeClose(); + } + }); + + request.signal.addEventListener('abort', () => { + unsub(); + safeClose(); + }); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store', + 'x-accel-buffering': 'no', + }, + }); +} diff --git a/proof-of-work/app/api/ai/generations/route.ts b/proof-of-work/app/api/ai/generations/route.ts index 1352241..1a61b35 100644 --- a/proof-of-work/app/api/ai/generations/route.ts +++ b/proof-of-work/app/api/ai/generations/route.ts @@ -28,6 +28,7 @@ export async function GET(request: NextRequest) { model: true, tokensIn: true, tokensOut: true, + durationMs: true, status: true, errorMessage: true, appliedProgramId: true, diff --git a/proof-of-work/app/api/ai/ollama/models/route.ts b/proof-of-work/app/api/ai/ollama/models/route.ts new file mode 100644 index 0000000..957079d --- /dev/null +++ b/proof-of-work/app/api/ai/ollama/models/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; + +/** + * GET /api/ai/ollama/models?baseUrl=... + * + * Probes Ollama at the supplied baseUrl (or http://ollama.startos:11434 + * by default) and returns the list of installed models, plus a status + * flag the UI uses to decide whether to: + * - pre-fill the URL field + * - render a model dropdown vs a free-text input + * - show a "no models installed yet" hint + * + * Authenticated route — we don't want unauthenticated visitors fingerprinting + * the local network. + * + * Response: + * { ok: true, baseUrl, models: [{ name, sizeBytes, modifiedAt }], ms } + * { ok: false, baseUrl, error, ms } + */ + +const PROBE_TIMEOUT_MS = 5_000; + +const DEFAULT_CANDIDATES = [ + 'http://ollama.startos:11434', + 'http://ollama.embassy:11434', +]; + +export async function GET(request: NextRequest) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 }); + + const url = new URL(request.url); + const explicit = url.searchParams.get('baseUrl'); + + // If the caller specified a URL, probe just that. Otherwise walk the + // candidate list and return the first that responds (so the UI can + // auto-discover whether the user runs ollama.startos OR ollama.embassy). + const candidates = explicit ? [explicit] : DEFAULT_CANDIDATES; + + for (const candidate of candidates) { + const result = await probe(candidate); + if (result.ok) return NextResponse.json(result); + // For an explicit URL, return the failure right away. + if (explicit) return NextResponse.json(result); + } + return NextResponse.json({ + ok: false, + baseUrl: candidates[0], + error: 'No Ollama instance responded at the default StartOS addresses.', + ms: 0, + }); +} + +async function probe(baseUrl: string) { + const t0 = Date.now(); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS); + try { + const res = await fetch(baseUrl.replace(/\/$/, '') + '/api/tags', { + signal: ctrl.signal, + }); + clearTimeout(timer); + if (!res.ok) { + return { + ok: false as const, + baseUrl, + error: `Ollama returned HTTP ${res.status}`, + ms: Date.now() - t0, + }; + } + const body = (await res.json()) as { + models?: Array<{ + name: string; + size?: number; + modified_at?: string; + }>; + }; + return { + ok: true as const, + baseUrl, + models: (body.models ?? []).map((m) => ({ + name: m.name, + sizeBytes: m.size ?? null, + modifiedAt: m.modified_at ?? null, + })), + ms: Date.now() - t0, + }; + } catch (e) { + clearTimeout(timer); + return { + ok: false as const, + baseUrl, + error: + ctrl.signal.aborted + ? `Timed out after ${PROBE_TIMEOUT_MS / 1000}s` + : (e as Error).message, + ms: Date.now() - t0, + }; + } +} diff --git a/proof-of-work/app/api/ai/test/route.ts b/proof-of-work/app/api/ai/test/route.ts index 4904927..729bb5e 100644 --- a/proof-of-work/app/api/ai/test/route.ts +++ b/proof-of-work/app/api/ai/test/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; import { getCurrentUser } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { getProvider } from '@/lib/ai/providers'; @@ -6,44 +7,115 @@ import { getProvider } from '@/lib/ai/providers'; /** * POST /api/ai/test * - * Sends a tiny "say hi in 3 words" prompt to the user's currently - * configured AI provider and reports success/failure inline. Lets - * the operator validate provider/model/key/baseUrl without going - * through a full program generation. + * Body (optional): + * { + * // If supplied: test this draft config without saving it. + * // Otherwise: test the actor's currently active config. + * provider?: string, + * model?: string, + * baseUrl?: string, + * apiKey?: string, + * // If supplied + apiKey is null: pull the saved key for that + * // profile (so the UI can test a saved profile by id without + * // forcing the user to re-type the key). + * useSavedKeyForId?: string, + * } * - * Returns: - * { ok: true, sample: "Hello there friend", tokensIn?, tokensOut?, ms } - * { ok: false, error: "..." } + * Sends a tiny "say hi in 3 words" prompt. Reports latency, sample + * reply (or finishReason if Gemini blocks it). * - * Times out after 30s — long enough for cold Ollama starts, short - * enough that a hung connection doesn't hang the UI. + * Times out after 30s — long enough for cold Ollama starts. */ const TEST_TIMEOUT_MS = 30_000; -export async function POST() { +const bodySchema = z.object({ + provider: z.string().optional(), + model: z.string().optional(), + baseUrl: z.string().nullable().optional(), + apiKey: z.string().nullable().optional(), + useSavedKeyForId: z.string().optional(), +}); + +export async function POST(request: NextRequest) { const user = await getCurrentUser(); if (!user) { return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 }); } - const prefs = await prisma.userPreferences.findUnique({ - where: { userId: user.id }, - select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true }, - }); - if (!prefs?.aiProvider || !prefs?.aiModel) { + const raw = await request.json().catch(() => ({})); + const parsed = bodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { ok: false, error: 'Invalid body' }, + { status: 400 }, + ); + } + const draft = parsed.data; + + // Resolve the config to test: + // 1. If draft.provider is set → use the draft fields (testing + // a not-yet-saved config in the UI). + // 2. Else if draft.useSavedKeyForId is set → load that profile. + // 3. Else → use the active config (legacy single-config columns). + let provider: string | null; + let model: string | null; + let baseUrl: string | null; + let apiKey: string | null; + + if (draft.provider) { + provider = draft.provider; + model = draft.model ?? null; + baseUrl = draft.baseUrl ?? null; + apiKey = draft.apiKey ?? null; + // Allow the UI to fill in just provider+model+baseUrl and have + // us pull the saved key by profile id (so the user doesn't have + // to retype it just to retest). + if (draft.useSavedKeyForId && (apiKey == null || apiKey === '')) { + const saved = await prisma.aIConfigProfile.findFirst({ + where: { id: draft.useSavedKeyForId, userId: user.id }, + select: { apiKey: true }, + }); + if (saved?.apiKey) apiKey = saved.apiKey; + } + } else if (draft.useSavedKeyForId) { + const saved = await prisma.aIConfigProfile.findFirst({ + where: { id: draft.useSavedKeyForId, userId: user.id }, + }); + if (!saved) { + return NextResponse.json( + { ok: false, error: 'Config not found.' }, + { status: 404 }, + ); + } + provider = saved.provider; + model = saved.model; + baseUrl = saved.baseUrl; + apiKey = saved.apiKey; + } else { + const prefs = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true }, + }); + provider = prefs?.aiProvider ?? null; + model = prefs?.aiModel ?? null; + baseUrl = prefs?.aiBaseUrl ?? null; + apiKey = prefs?.aiApiKey ?? null; + } + + if (!provider || !model) { return NextResponse.json( { ok: false, - error: 'Pick a provider + model in Settings → AI integration first.', + error: 'Pick a provider + model first.', }, { status: 400 }, ); } - const provider = getProvider(prefs.aiProvider); - if (!provider) { + const providerImpl = getProvider(provider); + if (!providerImpl) { return NextResponse.json( - { ok: false, error: `Unknown provider: ${prefs.aiProvider}` }, + { ok: false, error: `Unknown provider: ${provider}` }, { status: 400 }, ); } @@ -58,14 +130,18 @@ export async function POST() { let providerError: string | null = null; try { - for await (const chunk of provider.generate({ - apiKey: prefs.aiApiKey, - baseUrl: prefs.aiBaseUrl, - model: prefs.aiModel, + for await (const chunk of providerImpl.generate({ + apiKey, + baseUrl, + model, systemPrompt: - 'You are a connectivity test. Reply with exactly three words: "Hello there friend." Nothing else.', + 'You are a connectivity test. Reply with EXACTLY three words: "Hello there friend." Nothing else.', userPrompt: 'Say hi.', signal: controller.signal, + // Generous output budget so thinking models (Gemini 2.5/3.x, + // OpenAI o-series) actually have room to emit visible text after + // their internal reasoning. Cheap because the prompt is tiny. + maxOutputTokens: 4096, })) { if (chunk.type === 'text') sample += chunk.delta; else if (chunk.type === 'usage') { @@ -94,7 +170,11 @@ export async function POST() { { ok: false, error: - 'Got an empty response. The model returned successfully but with no text — check the model name and try again.', + 'Empty reply. The provider returned a response with no text. ' + + 'For Gemini this often means a safety filter blocked the output ' + + '(check the model name + try a flagship model). For thinking ' + + 'models the answer may have been spent on internal reasoning — ' + + 'try a non-thinking model.', ms, }, { status: 200 }, diff --git a/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts b/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts index dccaf9b..d23cf62 100644 --- a/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts +++ b/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts @@ -57,21 +57,35 @@ export async function POST( ); } + // v1.1.0:4: pull the user's preferred weight unit so we can fall + // back to it when the program day didn't specify one. + const prefs = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + select: { defaultWeightUnit: true }, + }); + const userPrefUnit = prefs?.defaultWeightUnit ?? "lbs"; + // Build SetLog rows: for each planned exercise, pre-create N // empty sets where N = exercise.sets ?? 1. The user fills in - // reps/weight when they actually do them. + // reps/weight when they actually do them. v1.1.0:4: if the + // ProgramExercise has a `suggestedWeight`, seed it on every set + // so the user starts with a target instead of a blank field. const setLogsCreate: { exerciseId: string; setNumber: number; + weight: number | null; weightUnit: string; }[] = []; for (const ex of day.exercises) { const setCount = ex.sets ?? 1; + const unit = + ex.suggestedWeightUnit ?? ex.exercise.defaultWeightUnit ?? userPrefUnit; for (let n = 1; n <= setCount; n++) { setLogsCreate.push({ exerciseId: ex.exerciseId, setNumber: n, - weightUnit: ex.exercise.defaultWeightUnit ?? "lbs", + weight: ex.suggestedWeight ?? null, + weightUnit: unit, }); } } diff --git a/proof-of-work/app/main/ai/history/[id]/page.tsx b/proof-of-work/app/main/ai/history/[id]/page.tsx new file mode 100644 index 0000000..e28eb55 --- /dev/null +++ b/proof-of-work/app/main/ai/history/[id]/page.tsx @@ -0,0 +1,90 @@ +import { redirect, notFound } from 'next/navigation'; +import Link from 'next/link'; +import { ChevronLeft } from 'lucide-react'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import GenerationDetail from '@/components/ai/GenerationDetail'; + +export const dynamic = 'force-dynamic'; + +/** + * v1.1.0:4 — Detail view for a single AIGeneration row. + * + * Why: previously a generation that finished while you weren't watching + * disappeared into a List that only showed metadata. To re-examine the + * model's output you had to apply it (which committed a Program). This + * page lets you see the parsed program tree first, then either: + * - Apply it (creates a Program — same flow as Generate's preview) + * - Re-generate from the same prompt + * - View the raw model response + the exact system/user prompts sent + * + * Status flows: + * pending → progress + stream attach (so reloading the page during + * a long Ollama run picks up where it left off) + * completed → static program tree + Apply + * applied → "View applied program" link + * failed → error + raw response details + */ +export default async function GenerationDetailPage({ + params, +}: { + params: { id: string }; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/auth/login'); + + const [row, exercises] = await Promise.all([ + prisma.aIGeneration.findFirst({ + where: { id: params.id, userId: user.id }, + }), + prisma.exercise.findMany({ + where: { userId: user.id }, + select: { id: true, name: true, type: true }, + orderBy: [{ type: 'asc' }, { name: 'asc' }], + }), + ]); + if (!row) notFound(); + + return ( +
+
+
+ + + +

+ AI · Generation +

+
+
+
+ +
+
+ ); +} diff --git a/proof-of-work/app/main/ai/history/page.tsx b/proof-of-work/app/main/ai/history/page.tsx index 43b5be5..a0cb204 100644 --- a/proof-of-work/app/main/ai/history/page.tsx +++ b/proof-of-work/app/main/ai/history/page.tsx @@ -23,6 +23,7 @@ export default async function HistoryPage() { model: true, tokensIn: true, tokensOut: true, + durationMs: true, status: true, errorMessage: true, appliedProgramId: true, diff --git a/proof-of-work/app/main/layout.tsx b/proof-of-work/app/main/layout.tsx index ea10056..18009aa 100644 --- a/proof-of-work/app/main/layout.tsx +++ b/proof-of-work/app/main/layout.tsx @@ -15,7 +15,10 @@ export default async function MainLayout({ return (
- +
{children}
diff --git a/proof-of-work/app/main/navigation.tsx b/proof-of-work/app/main/navigation.tsx index 380b383..f806c8a 100644 --- a/proof-of-work/app/main/navigation.tsx +++ b/proof-of-work/app/main/navigation.tsx @@ -14,23 +14,76 @@ import { logoutAction } from './actions'; interface NavigationProps { userName: string; + isAdmin: boolean; } -const navLinks = [ +interface NavSubItem { + /** Either a route href or a section anchor (#…) on the parent page. */ + href: string; + label: string; + /** Admin-only — hidden for non-admin users. */ + adminOnly?: boolean; +} + +interface NavLink { + href: string; + label: string; + icon: typeof LayoutDashboard; + /** v1.1.0:4 — sub-navigation rendered when the user is on this section. + * Items can either deep-link to a sibling route or scroll to an anchor + * on the parent page. */ + subItems?: NavSubItem[]; +} + +const navLinks: NavLink[] = [ { 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/ai', + label: 'AI', + icon: Sparkles, + subItems: [ + { href: '/main/ai/generate', label: 'Generate' }, + { href: '/main/ai/history', label: 'History' }, + { href: '/main/ai/templates', label: 'Templates' }, + ], + }, { href: '/main/exercises', label: 'Exercises', icon: ListChecks }, - { href: '/main/settings', label: 'Settings', icon: Settings }, + { + href: '/main/settings', + label: 'Settings', + icon: Settings, + subItems: [ + { href: '/main/settings#general', label: 'General' }, + { href: '/main/settings#password', label: 'Password' }, + { href: '/main/settings#sessions', label: 'Sessions' }, + { href: '/main/settings#ai', label: 'AI integration' }, + { href: '/main/settings#data', label: 'Export & import' }, + { href: '/main/settings#instance', label: 'Instance', adminOnly: true }, + { href: '/main/settings#danger', label: 'Danger zone' }, + ], + }, ]; -export default function Navigation({ userName }: NavigationProps) { +export default function Navigation({ userName, isAdmin }: NavigationProps) { const pathname = usePathname(); const router = useRouter(); - const isActive = (href: string) => { - return pathname === href || pathname.startsWith(href + '/'); + // A top-level item is "active" if the current pathname matches it + // exactly OR is a subpage. We use this to decide whether to expand + // the sub-nav under it. + const isActive = (href: string) => + pathname === href || pathname.startsWith(href + '/'); + + // A sub-item's active state depends on what it points to: + // - Route subitem (no #): exact pathname match + // - Anchor subitem (has #): always inactive in nav (anchor change + // doesn't fire pathname). The browser handles the highlight. + const isSubActive = (subHref: string) => { + const [path] = subHref.split('#'); + if (subHref.includes('#')) return false; + return pathname === path; }; const handleLogout = async () => { @@ -46,24 +99,50 @@ export default function Navigation({ userName }: NavigationProps) {

Proof of Work

-