7a62690a4a
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.
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|