v1.1.0:4 — multi-config AI, background generation, ollama auto-detect, system prompt overhaul
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.
This commit is contained in:
@@ -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<string, string> = {
|
||||
claude: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
'openai-compatible': 'Custom',
|
||||
gemini: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
const label = PRETTY[provider] ?? provider;
|
||||
return `${label} · ${model}`;
|
||||
}
|
||||
Reference in New Issue
Block a user