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>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
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<string, string | null> = {};
|
|
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 });
|
|
}
|