v1.1.0:2 — model-agnostic AI program generation (5 providers)

Five providers behind one streaming abstraction:
  - claude              (Anthropic)
  - openai              (api.openai.com)
  - openai-compatible   (any base URL — OpenRouter / LiteLLM /
                         vLLM / Together / your own gateway)
  - gemini              (Google)
  - ollama              (self-hosted; no key; LAN URL like
                         http://ollama.embassy:11434)

The "self-hosted Ollama on Start9" angle is the killer use case —
configure Settings → AI integration with the LAN URL of your Ollama
service and no API keys ever leave your network.

Architecture
  - lib/ai/types.ts              LLMProvider streaming interface
  - lib/ai/sse.ts                shared SSE + NDJSON line iterators
  - lib/ai/providers/*.ts        5 implementations + factory
  - lib/ai/programSchema.ts      Zod schema + JSON-schema-for-prompt +
                                  parseAIProgram with markdown-fence
                                  stripping and balanced-brace JSON
                                  extraction
  - lib/ai/apply.ts              materializes parsed AIProgram into
                                  Program tree (validates exerciseIds,
                                  rejects unresolved nulls, atomic
                                  transaction, sets aiGenerated=true)

Schema
  - UserPreferences gets aiProvider/aiModel/aiBaseUrl/aiApiKey
    (plaintext — same threat model as the rest of /data). Dead
    enableClaudeAI/claudeApiKey columns from v1.0.0:1-7 stay as
    no-op fields.
  - AIPromptTemplate (userId nullable; userId=NULL = built-in)
  - AIGeneration (raw response + parsed program + status +
    appliedProgramId + token counts)
  - All compat-ALTER'd in docker_entrypoint.sh on first boot.

API
  - POST   /api/ai/generate              SSE streaming: emits
                                           generation/text/usage/complete
                                           events; persists AIGeneration
                                           row up front so failures show
                                           in history too
  - POST   /api/ai/apply                 takes user-edited AIProgram,
                                           creates Program, marks
                                           generation as applied
  - GET    /api/ai/templates             built-ins + this user's own
  - POST   /api/ai/templates             create user-owned template
  - PATCH  /api/ai/templates/[id]        edit; built-ins admin-only
  - DELETE /api/ai/templates/[id]        delete; built-ins admin-only
  - GET    /api/ai/generations           list (paginated)
  - GET    /api/ai/generations/[id]      full row
  - DELETE /api/ai/generations/[id]      delete one (Program survives)
  - GET    /api/ai/config                returns aiKeyConfigured flag,
                                           never plaintext key
  - POST   /api/ai/config                update provider config
  - DELETE /api/admin/ai/generations     admin-only "clear all" with
                                           optional userId / olderThanDays

UI
  - Settings → AI integration            provider/model/URL/key form;
                                           plaintext key warning visible
  - /main/ai                             hub page with cards
  - /main/ai/generate                    template picker + textarea +
                                           live SSE stream + cancel +
                                           ProgramPreview with inline
                                           unknown-exercise resolver +
                                           apply button + redirect to
                                           the new Program
  - /main/ai/templates                   list + create + edit + delete;
                                           per-row "show prompt" expand;
                                           built-in delete warns about
                                           reconcile re-creation
  - /main/ai/history                     list + delete; status badges;
                                           link to applied Program
  - Nav: "AI" entry between Programs and Exercises (Sparkles icon)

Built-in templates
  - prisma/aiTemplates.seed.json: 5 starter templates (hypertrophy /
    strength / endurance / recovery / custom)
  - prisma/ensurePromptTemplates.cjs: per-boot reconcile,
    INSERT-or-UPDATE keyed on (userId IS NULL AND name=...);
    user-created templates never touched

Tests
  - tests/ai-programSchema.test.ts: extractJson + parseAIProgram
    edge cases (markdown fences, balanced braces, malformed JSON,
    Zod shape rejection, unresolved-exerciseId tolerance)
  - tests/ai-apply.test.ts: materializes valid AIProgram, rejects
    cross-user exerciseIds, rejects unresolved exercises, honors
    isActive flag
  - tests/routes-ai-templates.test.ts: built-in vs user permissions,
    cross-user template isolation, /api/ai/config plaintext-key safety,
    provider enum validation
  - 123 tests across 14 files, all passing.

No data migration. Existing /data is augmented with the new columns
+ tables only.
This commit is contained in:
Keysat
2026-05-10 15:35:35 -05:00
parent 3a5b929284
commit 974c3eb07d
36 changed files with 4206 additions and 1 deletions
+80
View File
@@ -105,6 +105,73 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" "CREATE INDEX IF NOT EXISTS Workout_programDayId_idx ON Workout(programDayId);"
fi
# v1.1.0:2 added the model-agnostic AI configuration fields to
# UserPreferences. Replaces the dead enableClaudeAI / claudeApiKey
# single-provider scheme (those columns stay as no-op fields for
# back-compat).
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('UserPreferences');" 2>/dev/null | grep -q "|aiProvider|"; then
log "adding AI configuration columns to UserPreferences"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiProvider TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiModel TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiBaseUrl TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiApiKey TEXT;"
fi
# v1.1.0:2 also added AIPromptTemplate + AIGeneration tables.
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIPromptTemplate';" \
2>/dev/null | grep -q AIPromptTemplate; then
log "creating AIPromptTemplate table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIPromptTemplate (
id TEXT PRIMARY KEY,
userId TEXT,
name TEXT NOT NULL,
description TEXT,
systemPrompt TEXT NOT NULL,
userPromptTemplate TEXT NOT NULL,
isBuiltIn INTEGER NOT NULL DEFAULT 0,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIPromptTemplate_userId_idx ON AIPromptTemplate(userId);
CREATE INDEX AIPromptTemplate_isBuiltIn_idx ON AIPromptTemplate(isBuiltIn);
"
fi
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIGeneration';" \
2>/dev/null | grep -q AIGeneration; then
log "creating AIGeneration table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIGeneration (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
templateId TEXT,
templateName TEXT,
userInput TEXT NOT NULL,
systemPrompt TEXT NOT NULL,
userPrompt TEXT NOT NULL,
rawResponse TEXT,
parsedProgram TEXT,
provider TEXT NOT NULL,
model TEXT NOT NULL,
tokensIn INTEGER,
tokensOut INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
errorMessage TEXT,
appliedProgramId TEXT,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIGeneration_userId_createdAt_idx ON AIGeneration(userId, createdAt);
CREATE INDEX AIGeneration_status_idx ON AIGeneration(status);
CREATE INDEX AIGeneration_appliedProgramId_idx ON AIGeneration(appliedProgramId);
"
fi
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
2>/dev/null | grep -q InstanceSettings; then
@@ -173,6 +240,19 @@ else
log "skipping library ensure (json or db not found)"
fi
# v1.1.0:2 — reconcile built-in AI prompt templates from the curated
# JSON. Same INSERT-or-UPDATE pattern as the exercise library, scoped
# to userId IS NULL so user-created templates are never touched.
TEMPLATES_JSON_PATH="${WORKOUT_TEMPLATES_JSON_PATH:-/app/prisma/aiTemplates.seed.json}"
TEMPLATES_SCRIPT="/app/prisma/ensurePromptTemplates.cjs"
if [ -f "$TEMPLATES_JSON_PATH" ] && [ -f "$TEMPLATES_SCRIPT" ] && [ -f "$DB_PATH" ]; then
log "ensuring built-in AI prompt templates are present"
node "$TEMPLATES_SCRIPT" \
--db "$DB_PATH" \
--json "$TEMPLATES_JSON_PATH" \
|| log "WARNING: ensurePromptTemplates failed; continuing boot"
fi
# -----------------------------------------------------------------------------
# Step 4 — launch the app.
# -----------------------------------------------------------------------------
+5 -1
View File
@@ -7,6 +7,7 @@ import { v_1_0_0_5 } from './v1.0.0.5'
import { v_1_0_0_6 } from './v1.0.0.6'
import { v_1_0_0_7 } from './v1.0.0.7'
import { v_1_1_0_1 } from './v1.1.0.1'
import { v_1_1_0_2 } from './v1.1.0.2'
/**
* Version graph for the `proof-of-work` package.
@@ -22,9 +23,11 @@ import { v_1_1_0_1 } from './v1.1.0.1'
* v1.0.0:6 — paginate workout history (infinite scroll).
* v1.0.0:7 — exercise library cleanup, photo-import removal.
* v1.1.0:1 — Programs UI (manual create / save / follow).
* v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI /
* OpenAI-compatible / Gemini / Ollama).
*/
export const versionGraph = VersionGraph.of({
current: v_1_1_0_1,
current: v_1_1_0_2,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -33,5 +36,6 @@ export const versionGraph = VersionGraph.of({
v_1_0_0_5,
v_1_0_0_6,
v_1_0_0_7,
v_1_1_0_1,
],
})
+62
View File
@@ -0,0 +1,62 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.1.0:2 — model-agnostic AI program generation.
*
* Five providers behind one streaming abstraction:
*
* - claude (Anthropic)
* - openai (api.openai.com)
* - openai-compatible (any base URL — OpenRouter / LiteLLM /
* vLLM / Together / your own gateway)
* - gemini (Google)
* - ollama (self-hosted; no key; uses LAN URL)
*
* The "self-hosted Ollama on Start9" angle is the killer use case —
* point Settings → AI integration → Base URL at
* `http://ollama.embassy:11434` (or whatever Ollama service you have
* on the same StartOS host) and no API keys ever leave your network.
*
* Workflow
* 1. Settings → AI integration: pick provider + model + key/URL.
* 2. AI → Generate program: pick a template, type your specifics
* ("8 weeks heavy leg emphasis"), click Generate.
* 3. Watch the response stream in word-by-word via SSE.
* 4. Server validates the JSON output against a Zod schema and
* stores both raw + parsed in AIGeneration.
* 5. Preview UI shows the program tree. Unknown exercises (the
* model picked something not in your library) are highlighted
* and you can map them to existing entries or remove them.
* 6. Apply → materializes into a real Program (same schema/UI as
* the v1.1.0:1 manual programs).
*
* Ships with 5 built-in prompt templates (hypertrophy block,
* strength block, endurance/running block, recovery week, custom).
* Built-ins reconcile per-boot the same way curated exercises do.
* Both admin and regular users can create their own templates.
*
* Schema additions
* - UserPreferences: aiProvider, aiModel, aiBaseUrl, aiApiKey
* (plaintext — consistent with the rest of /data/app.db; the
* host-level threat model assumes the operator owns /data).
* - AIPromptTemplate (built-ins userId=NULL, user templates
* userId=<them>).
* - AIGeneration (one row per generate request; raw response,
* parsed program, status, applied program id, token counts).
*
* Backward compatible: existing UserPreferences rows get the new
* columns added with NULL defaults (compat ALTER on first boot);
* the dead enableClaudeAI / claudeApiKey columns from v1.0.0:1-7
* stay as no-op fields.
*/
export const v_1_1_0_2 = VersionInfo.of({
version: '1.1.0:2',
releaseNotes: {
en_US:
'AI program generation. Pick from Claude / OpenAI / Gemini / OpenAI-compatible / self-hosted Ollama. Settings → AI integration to configure (Ollama on Start9 needs no API key). AI → Generate program to pick a template, describe what you want, watch the response stream in, review the parsed program, and apply it to your Programs library. Ships 5 starter templates; both admin and regular users can create their own. Generation history is kept until you delete it (per-row Trash; admin-only "clear all" via /api/admin/ai/generations).',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})