2b0abad68e
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).
Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).
Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.
Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
3.9 KiB
3.9 KiB
paths
| paths | ||
|---|---|---|
|
AI subsystem
Scoped guidance for the AI generation subsystem (proof-of-work/lib/ai/** and the
generate/generations route handlers). Whole-repo rules live in AGENTS.md.
Architecture
generate/route.tskicks off a detached background runner (generationRunner.ts) and returns an id; the client attaches via SSE (generations/[id]/stream) and can also poll the row. Navigating away does NOT cancel generation.- System prompt =
systemPromptBase.ts(output contract: JSON-only, libraryexerciseIds only, suggested weights) + the template's coaching prompt +PROGRAM_OUTPUT_SHAPE+ library + optional history block (historyContext.ts). - Multi-config:
AIConfigProfilerows per user;UserPreferences.activeAIConfigIdpoints at the active one and is mirrored into the legacyai*columns for back-compat.
Two generation kinds (AIGeneration.kind)
The runner spine is shared by two output shapes, discriminated by AIGeneration.kind
("program" | "workout", default "program"). The runner picks the parser by kind and
stores the JSON in the (reused) parsedProgram column.
- program (
kind: 'program') —generate/route.ts→programSchema.ts(PROGRAM_OUTPUT_SHAPE/parseAIProgram). Applied to DB rows viaapply.ts. Shown in AI · History (which filterskind: 'program'). - workout (
kind: 'workout') —generate-workout/route.ts(usesworkoutPrompt.ts+workoutSchema.ts:WORKOUT_OUTPUT_SHAPE/parseAIWorkout). A single day's session. No server-side apply: the client (GenerateWorkoutClient.tsx) stashes the reviewed suggestion insessionStorageand routes to/main/workouts/new?from=ai, whereAiWorkoutPrefill.tsxexpands it (viaworkoutDraft.ts::buildPrefillExercises) and pre-fills the normalWorkoutForm— nothing persists until the user saves through the regular workout path. Refine = a new workout generation seeded with the prior suggestion JSON (priorWorkoutin the route body → REVISION mode inworkoutPrompt.ts). These rows are ephemeral, so they're excluded from the program-shaped AI · History. - Adding a new kind: extend the union in
KickoffOpts, add a parser + output-shape, branch the parser selection ingenerationRunner.ts, and decide whether it belongs in History (filtered by kind).
Provider abstraction
- Each provider yields an async iterable of
GenerateChunk(text/usage/done/error); add new ones underlib/ai/providers/and register inindex.ts.openai.tsexports bothopenaiandopenai-compatible, so the four provider files register 5 providers (claude,openai,openai-compatible,gemini,ollama). - Streaming AI uses SSE; partial JSON is recovered with
lib/ai/lenientJson.ts. - Pricing/model menus live in
lib/ai/pricing.ts(PRICES,MODEL_MENU) — keep them paired so every menu model has a price entry (there's a test enforcing this).
SSRF / provider-URL safety
- Any
fetchto a user-supplied provider base URL MUST go throughassertSafeProviderUrl(lib/ai/safeUrl.ts) first — it enforces http(s) and blocks link-local/cloud-metadata (169.254/16, fe80::/10) + unspecified. Private-LAN + loopback are allowed on purpose (reachingollama.startos/LAN gateways is the feature). Currently wired intoproviders/ollama.ts, theopenai-compatiblepath inproviders/openai.ts(NOT the fixedapi.openai.compath), and theai/ollama/modelsprobe. Add the guard to any new user-URL fetch path. - Custom-URL providers (those with
requiresBaseUrl: ollama, openai-compatible) are admin-only —isCustomUrlProvidergatesai/configsPOST +[id]PATCH +ai/test, andai/ollama/modelsis fully admin-only. The Settings UI hides them from non-admins. This is a second defense layer on top of the IP block; keep both when adding routes.