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.
104 lines
3.0 KiB
TypeScript
104 lines
3.0 KiB
TypeScript
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
|
import { sseLines } from '../sse';
|
|
|
|
/**
|
|
* Anthropic Claude: SSE over POST /v1/messages.
|
|
*
|
|
* Streaming events:
|
|
* - message_start → has usage.input_tokens
|
|
* - content_block_delta (type=text_delta) → has delta.text
|
|
* - message_delta → has usage.output_tokens
|
|
* - message_stop → terminal
|
|
* - error → fatal
|
|
*/
|
|
export const claude: LLMProvider = {
|
|
id: 'claude',
|
|
label: 'Anthropic Claude',
|
|
requiresApiKey: true,
|
|
requiresBaseUrl: false,
|
|
|
|
async *generate(opts: GenerateOpts): AsyncGenerator<GenerateChunk, void, void> {
|
|
if (!opts.apiKey) {
|
|
yield { type: 'error', message: 'Claude API key is required.' };
|
|
return;
|
|
}
|
|
const url = 'https://api.anthropic.com/v1/messages';
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-api-key': opts.apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model: opts.model,
|
|
max_tokens: opts.maxOutputTokens ?? 8000,
|
|
stream: true,
|
|
system: opts.systemPrompt,
|
|
messages: [{ role: 'user', content: opts.userPrompt }],
|
|
}),
|
|
signal: opts.signal,
|
|
});
|
|
} catch (e) {
|
|
yield {
|
|
type: 'error',
|
|
message: `Claude unreachable: ${(e as Error).message}`,
|
|
};
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
yield {
|
|
type: 'error',
|
|
message: `Claude HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
|
|
};
|
|
return;
|
|
}
|
|
let tokensIn: number | undefined;
|
|
let tokensOut: number | undefined;
|
|
try {
|
|
for await (const data of sseLines(res)) {
|
|
if (data === '[DONE]') break;
|
|
let evt: any;
|
|
try {
|
|
evt = JSON.parse(data);
|
|
} catch {
|
|
continue;
|
|
}
|
|
switch (evt.type) {
|
|
case 'message_start':
|
|
tokensIn = evt.message?.usage?.input_tokens;
|
|
break;
|
|
case 'content_block_delta':
|
|
if (evt.delta?.type === 'text_delta' && evt.delta.text) {
|
|
yield { type: 'text', delta: evt.delta.text };
|
|
}
|
|
break;
|
|
case 'message_delta':
|
|
if (evt.usage?.output_tokens != null) {
|
|
tokensOut = evt.usage.output_tokens;
|
|
}
|
|
break;
|
|
case 'message_stop':
|
|
// terminal — fall through to outer break
|
|
break;
|
|
case 'error':
|
|
yield {
|
|
type: 'error',
|
|
message: evt.error?.message ?? 'Claude stream error',
|
|
};
|
|
return;
|
|
}
|
|
}
|
|
yield { type: 'usage', tokensIn, tokensOut };
|
|
yield { type: 'done' };
|
|
} catch (e) {
|
|
yield {
|
|
type: 'error',
|
|
message: `Claude stream error: ${(e as Error).message}`,
|
|
};
|
|
}
|
|
},
|
|
};
|