5e291203a5
User-feedback-driven release after testing v1.1.0:3. Nine themes:
1. Multi-config persistence
- New AIConfigProfile table (per-user). Save N configs, toggle one
active. Switching providers no longer wipes the previous setup.
- UserPreferences gains activeAIConfigId; legacy single-config
columns are mirrored from the active profile so existing reads
keep working without conditional logic.
- Idempotent boot migration lifts any existing single-config row
into a default profile.
2. Ollama auto-detect
- The "Add config" form probes /api/tags on the StartOS internal
addresses (ollama.startos / ollama.embassy on :11434). If
reachable: URL pre-fills, model field becomes a dropdown of
installed models. Fixes the copy-paste UX.
3. Curated model dropdowns for major providers
- Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
- OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
- Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
- "Other (type your own)" stays for niche models.
- Fixes "I tried gemini-3.0-pro and got 404."
4. Background generation
- lib/ai/generationRunner.ts: detached runner with in-memory
pub/sub bus. POST /api/ai/generate kicks it off and returns
immediately. SSE stream attaches by id. The runner survives
request cancellation; navigating away no longer kills it.
- New AIGeneration columns: progressText (in-flight stream),
durationMs (final wall-clock).
- Generate UI shows a banner explaining background-safety.
- History detail page polls progress + renders partial JSON
live for cross-process resume (page refresh, new tab).
5. System prompt overhaul
- lib/ai/systemPromptBase.ts: structural contract prepended to
every template. Forces JSON-only output, library-exerciseId
usage (kills "exerciseId doesn't belong to this user" errors),
and per-resistance-exercise suggestedWeight (with-history vs
without-history variants).
- aiExerciseSchema + ProgramExercise gain suggestedWeight +
suggestedWeightUnit. Starting a workout from a ProgramDay
pre-populates SetLog.weight from the suggestion.
6. Test connection improvements
- Latency in seconds (was ms — confusing for slow Ollama).
- Stale "✓ Connected" clears on form change.
- Per-config Test (no need to activate first).
- Generous maxOutputTokens for thinking models.
- Gemini surfaces finishReason on empty response (e.g. "blocked
by safety filter") instead of generic "empty response."
- Test endpoint accepts a draft body so you can verify before
saving + before activating.
7. History detail view
- Click row → full program tree + exact prompts sent. Apply from
here without re-generating. Pending rows poll for progress.
8. Sidebar sub-navigation
- AI: Generate / History / Templates
- Settings: General / Password / Sessions / AI integration /
Export / Instance (admin) / Danger zone, with anchor scroll.
9. API key UX
- "Key saved" indicator on saved configs (was confusing to see
an empty input after a successful save).
Schema migrations (additive, idempotent in entrypoint):
- AIConfigProfile table created
- UserPreferences.activeAIConfigId
- AIGeneration.progressText + durationMs
- ProgramExercise.suggestedWeight + suggestedWeightUnit
Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
4.8 KiB
TypeScript
165 lines
4.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
import { getCurrentUser } from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
import {
|
|
PROGRAM_OUTPUT_SHAPE,
|
|
} 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, includeHistory?: boolean }
|
|
*
|
|
* 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.
|
|
*
|
|
* Response:
|
|
* 201 { id: "...generationId..." }
|
|
* 400 { error: "..." }
|
|
*/
|
|
|
|
const bodySchema = z.object({
|
|
templateId: z.string().optional().nullable(),
|
|
userInput: z.string().min(1),
|
|
includeHistory: z.boolean().optional().default(false),
|
|
});
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
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 },
|
|
);
|
|
}
|
|
|
|
const prefs = await prisma.userPreferences.findUnique({
|
|
where: { userId: user.id },
|
|
});
|
|
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Load the template if provided.
|
|
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 NextResponse.json({ error: 'Template not found.' }, { status: 404 });
|
|
}
|
|
template = t;
|
|
}
|
|
|
|
// Library for the 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 [];
|
|
}
|
|
})(),
|
|
})),
|
|
);
|
|
|
|
// History context if requested.
|
|
let historyBlock = '';
|
|
if (parsed.data.includeHistory) {
|
|
const summary = await buildHistorySummary(prisma, user.id);
|
|
historyBlock = formatHistoryContext(summary);
|
|
}
|
|
|
|
// 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
|
|
|
|
${PROGRAM_OUTPUT_SHAPE}
|
|
|
|
# LIBRARY (use these exerciseIds; do not invent ids)
|
|
|
|
${libraryJson}${historyBlock}`;
|
|
|
|
const userPromptBody =
|
|
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
|
parsed.data.userInput;
|
|
|
|
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,
|
|
});
|
|
|
|
return NextResponse.json({ id }, { status: 201 });
|
|
}
|
|
|
|
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).`;
|